diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e3a03d2..fe9077c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -55,7 +55,7 @@ ROS View is a **browser-native** playback and visualization tool for robotics re |--------|---------|---------------|-------| | **MCAP** | `@mcap/core` + `@foxglove/mcap-support` | Full (chunk index) | Preferred; efficient interval queries | | **ROS1 `.bag`** | `@foxglove/rosbag` | Full (chunk index) | via `CachedFilelike` + `BrowserHttpReader` | -| **ROS2 `.db3`** | `@foxglove/rosbag2` + `sql.js` (WASM) | Local / remote requires full download | SQLite limitation; convert to MCAP for large remote files | +| **ROS2 `.db3`** | `@foxglove/rosbag2` + `sql.js` (WASM) | Local / remote (remote auto-downloads in full) | SQLite limitation, no Range streaming; convert to MCAP for large remote files | **Supported message encodings:** diff --git a/docs/ARCHITECTURE.zh.md b/docs/ARCHITECTURE.zh.md index a089f3f..5ff5add 100644 --- a/docs/ARCHITECTURE.zh.md +++ b/docs/ARCHITECTURE.zh.md @@ -47,7 +47,7 @@ |------|--------|------------|------| | **MCAP** | `@mcap/core` + `@foxglove/mcap-support` | 完整支持(索引读取) | 首选格式,有 chunk 索引可做高效区间查询 | | **ROS1 .bag** | `@foxglove/rosbag` | 完整支持(chunk 索引) | 通过 `CachedFilelike` + `BrowserHttpReader` 实现 | -| **ROS2 .db3** | `@foxglove/rosbag2` + `sql.js` (WASM) | 本地支持 / 远程需整文件下载 | SQLite 格式限制,远程大文件建议转 MCAP | +| **ROS2 .db3** | `@foxglove/rosbag2` + `sql.js` (WASM) | 本地 / 远程(远程自动整文件下载) | SQLite 格式限制,无法 Range 流式;远程大文件建议转 MCAP | | **HDF5** | `@ioai/hdf5` (WASM) | 部分读取 | 科学数据;浏览器内解析 | | **BVH** | 内置解析 | 不适用(非 ROS bag 类流) | 骨骼动作捕捉动画回放 | @@ -841,7 +841,7 @@ rosview/ │ ├── layout/ # Dockview、dockviewController、Tab 菜单 │ ├── viewer/ # 对外 `RosViewer`、内部实现与 `RosViewProvider` / Content │ ├── workspace/ # navbar、sidebar、common、playback - │ └── panels/ # 各面板目录 + framework + registry + image-core + │ └── panels/ # 各面板目录 + framework + registry(面板内 core/ 子目录) │ └── PANEL_CONTRACT.md # 面板契约 │ ├── shared/ diff --git a/docs/EMBEDDING.md b/docs/EMBEDDING.md index 41c3dbc..2c6a453 100644 --- a/docs/EMBEDDING.md +++ b/docs/EMBEDDING.md @@ -348,9 +348,9 @@ export async function GET(req: NextRequest) { } ``` -> **About `db3`:** ROSView does **not** support remote streaming of db3 (SQLite needs random access to the whole database). Download the db3 file in full as a `File` on the client and pass it via the `file` / `files` prop. mcap / bag can be streamed directly through the Range route above. +> **About `db3`:** Like the other formats, db3 supports both a **local `File`** and a **remote URL** — just point `url` at the Range route above. However, because SQLite needs random access to the whole database, db3 **cannot be Range-streamed** the way mcap / bag are: when given a db3 URL, ROSView **downloads the file in full inside the Worker** (with download progress) before opening it. For very large db3 files, prefer converting to MCAP for true streaming. You no longer need to download the db3 as a `File` yourself on the host side. -> **Version requirement:** Use **`@ioai/rosview` ≥ 1.3.5** (which depends on `@ioai/wasm-zstd` ≥ 1.1.2). 1.3.5 fixes the Turbopack inline-worker regression from 1.3.4 (`Failed to resolve module specifier './wasm-zstd-*.js'`, caused by the inline worker running from a `blob:` URL and unable to resolve the zstd glue's relative dynamic import). `@ioai/wasm-zstd` 1.1.2 statically inlines the glue into the worker, fixing this for good. +> **Version requirement:** Use **`@ioai/rosview` ≥ 1.3.5** (which depends on `@ioai/wasm-zstd` ≥ 1.1.2). 1.3.5 adds remote-URL loading for db3 and fixes the Turbopack inline-worker regression from 1.3.4 (`Failed to resolve module specifier './wasm-zstd-*.js'`, caused by the inline worker running from a `blob:` URL and unable to resolve the zstd glue's relative dynamic import). `@ioai/wasm-zstd` 1.1.2 statically inlines the glue into the worker, fixing this for good. --- diff --git a/docs/EMBEDDING.zh.md b/docs/EMBEDDING.zh.md index c800a21..2f49bab 100644 --- a/docs/EMBEDDING.zh.md +++ b/docs/EMBEDDING.zh.md @@ -348,9 +348,9 @@ export async function GET(req: NextRequest) { } ``` -> **关于 `db3`**:ROSView **不支持远程流式** db3(SQLite 需随机读取整库)。请在前端先把 db3 整文件下载为 `File`,再通过 `file` / `files` prop 传入。mcap / bag 则可直接用上面的 Range 接口流式加载。 +> **关于 `db3`**:db3 与其他格式一样,**本地 `File`** 与**远程 URL** 两种方式都支持,直接把上面 Range 接口的地址传给 `url` 即可。但由于 SQLite 需要随机访问整库,db3 **无法像 mcap / bag 那样按 Range 流式加载**——传入 db3 的 URL 时,ROSView 会在 Worker 内**自动整文件下载**后再打开(带下载进度)。因此对超大 db3,建议优先转换为 MCAP 以获得真正的流式体验。你无需再在宿主侧手动下载为 `File`。 -> **版本要求**:请使用 **`@ioai/rosview` ≥ 1.3.5**(依赖 `@ioai/wasm-zstd` ≥ 1.1.2)。1.3.5 修复了 1.3.4 在 Turbopack 下因 inline worker 以 `blob:` URL 运行、无法解析 zstd glue 相对动态 import 而抛出的 `Failed to resolve module specifier './wasm-zstd-*.js'`;`@ioai/wasm-zstd` 1.1.2 将 glue 静态内联进 worker,彻底解决该问题。 +> **版本要求**:请使用 **`@ioai/rosview` ≥ 1.3.5**(依赖 `@ioai/wasm-zstd` ≥ 1.1.2)。1.3.5 新增了 db3 的远程 URL 加载,并修复了 1.3.4 在 Turbopack 下因 inline worker 以 `blob:` URL 运行、无法解析 zstd glue 相对动态 import 而抛出的 `Failed to resolve module specifier './wasm-zstd-*.js'`;`@ioai/wasm-zstd` 1.1.2 将 glue 静态内联进 worker,彻底解决该问题。 --- diff --git a/package-lock.json b/package-lock.json index c6be19a..1d93359 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ioai/rosview", - "version": "1.3.5", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ioai/rosview", - "version": "1.3.5", + "version": "1.5.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.4", @@ -120,13 +120,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -135,9 +135,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -145,21 +145,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -176,14 +176,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -193,14 +193,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -210,9 +210,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -220,29 +220,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -252,9 +252,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -262,9 +262,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -272,9 +272,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -282,27 +282,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -312,9 +312,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", "engines": { @@ -322,33 +322,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -356,14 +356,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1209,13 +1209,13 @@ } }, "node_modules/@foxglove/rosmsg2-serialization": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@foxglove/rosmsg2-serialization/-/rosmsg2-serialization-3.1.1.tgz", - "integrity": "sha512-SOhgu7mprRMosSXICODoDwjMlqnnexDgTGnSXS13mQEUCshDOlY5wIwiwk4SpMcSg1Uyw8h7HzpgSrpdEpKIcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@foxglove/rosmsg2-serialization/-/rosmsg2-serialization-3.1.2.tgz", + "integrity": "sha512-KKtmHuuIQy6I0JLUi01LFhV7hrZb5xd+LegtuGz2+9L9pftBxtVVKhoxO6B/kQREGocIUOLP8494kxLF58LMWw==", "dev": true, "license": "MIT", "dependencies": { - "@foxglove/cdr": "^3.4.0", + "@foxglove/cdr": "^3.5.1", "@foxglove/message-definition": "0.5.0", "@foxglove/rostime": "^1.1.3" }, @@ -1223,6 +1223,16 @@ "node": ">= 20" } }, + "node_modules/@foxglove/rosmsg2-serialization/node_modules/@foxglove/cdr": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@foxglove/cdr/-/cdr-3.5.1.tgz", + "integrity": "sha512-Fubm04/xC1SkeNaviwUhbFk8nAiiNvIqlmP5MW7r6IVGFa35rsA0E3/Rg/pGdE77ZXf4XNwShZcQoEmje62ddQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/@foxglove/rostime": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@foxglove/rostime/-/rostime-1.1.3.tgz", @@ -1627,9 +1637,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -2759,9 +2769,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -2776,9 +2786,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -2793,9 +2803,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -2810,9 +2820,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -2827,9 +2837,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -2844,9 +2854,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -2861,9 +2871,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -2878,9 +2888,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -2895,9 +2905,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -2912,9 +2922,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -2929,9 +2939,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -2946,9 +2956,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -2963,9 +2973,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -2982,9 +2992,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -2999,9 +3009,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -3023,9 +3033,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3059,9 +3069,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", "cpu": [ "arm" ], @@ -3073,9 +3083,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", "cpu": [ "arm64" ], @@ -3087,9 +3097,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", "cpu": [ "arm64" ], @@ -3101,9 +3111,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", "cpu": [ "x64" ], @@ -3115,9 +3125,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", "cpu": [ "arm64" ], @@ -3129,9 +3139,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", "cpu": [ "x64" ], @@ -3143,9 +3153,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", "cpu": [ "arm" ], @@ -3157,9 +3167,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", "cpu": [ "arm" ], @@ -3171,9 +3181,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", "cpu": [ "arm64" ], @@ -3185,9 +3195,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", "cpu": [ "arm64" ], @@ -3199,9 +3209,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", "cpu": [ "loong64" ], @@ -3213,9 +3223,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", "cpu": [ "loong64" ], @@ -3227,9 +3237,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", "cpu": [ "ppc64" ], @@ -3241,9 +3251,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", "cpu": [ "ppc64" ], @@ -3255,9 +3265,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", "cpu": [ "riscv64" ], @@ -3269,9 +3279,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", "cpu": [ "riscv64" ], @@ -3283,9 +3293,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", "cpu": [ "s390x" ], @@ -3297,9 +3307,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", "cpu": [ "x64" ], @@ -3311,9 +3321,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", "cpu": [ "x64" ], @@ -3325,9 +3335,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", "cpu": [ "x64" ], @@ -3339,9 +3349,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", "cpu": [ "arm64" ], @@ -3353,9 +3363,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", "cpu": [ "arm64" ], @@ -3367,9 +3377,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", "cpu": [ "ia32" ], @@ -3381,9 +3391,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", "cpu": [ "x64" ], @@ -3395,9 +3405,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", "cpu": [ "x64" ], @@ -3673,9 +3683,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.16", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", + "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "dev": true, "license": "MIT", "dependencies": { @@ -3760,17 +3770,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", - "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/type-utils": "8.59.4", - "@typescript-eslint/utils": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3783,7 +3793,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.4", + "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -3799,16 +3809,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", - "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "engines": { @@ -3824,14 +3834,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", - "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.4", - "@typescript-eslint/types": "^8.59.4", + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "engines": { @@ -3846,14 +3856,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", - "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4" + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3864,9 +3874,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", - "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", "dev": true, "license": "MIT", "engines": { @@ -3881,15 +3891,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", - "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3906,9 +3916,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", - "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", "dev": true, "license": "MIT", "engines": { @@ -3920,16 +3930,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", - "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.4", - "@typescript-eslint/tsconfig-utils": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4000,16 +4010,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", - "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4" + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4024,13 +4034,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", - "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -4101,16 +4111,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4119,13 +4129,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4156,9 +4166,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -4169,13 +4179,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -4183,14 +4193,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4199,9 +4209,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -4209,13 +4219,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4253,28 +4263,28 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", - "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", + "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.3", - "@vue/shared": "3.5.34", + "@vue/shared": "3.5.35", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", - "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", + "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.34", - "@vue/shared": "3.5.34" + "@vue/compiler-core": "3.5.35", + "@vue/shared": "3.5.35" } }, "node_modules/@vue/compiler-vue2": { @@ -4314,9 +4324,9 @@ } }, "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -4340,9 +4350,9 @@ } }, "node_modules/@vue/shared": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", - "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", + "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", "dev": true, "license": "MIT" }, @@ -4582,9 +4592,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4618,9 +4628,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -4980,9 +4990,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.4", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.4.tgz", - "integrity": "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "dev": true, "license": "MIT", "engines": { @@ -5073,22 +5083,22 @@ "license": "MIT" }, "node_modules/dockview": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/dockview/-/dockview-6.5.0.tgz", - "integrity": "sha512-RkCJgxM6BIhS3mlULbsutecGFfjXkyeZszMQX0fEeOg0NpziU6TE4q8qsoocvwD0q8ya2ecJQsNKF3a4E5FPNg==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/dockview/-/dockview-6.6.1.tgz", + "integrity": "sha512-vBnxWVk0f665z1Zyx598l/BMUjHD3SK5V4XorxpYGjaSREM8lLc8O6ylgKTtw+ZnCOMYOhcwov9x0ubfFazvMw==", "dev": true, "license": "MIT", "dependencies": { - "dockview-core": "^6.5.0" + "dockview-core": "^6.6.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/dockview-core": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/dockview-core/-/dockview-core-6.5.0.tgz", - "integrity": "sha512-ywdlF95ISqk0IiYot6dg9oyRyri4xA++SJTGSiegkn6KUPKVxyL8hXnqHWuV5U23eNvUNyiEP3CsoGSozy0SUA==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/dockview-core/-/dockview-core-6.6.1.tgz", + "integrity": "sha512-WuNoO2wl3rGI8MaTuvmfgViek4WafHLD9Kf//Hw69g8TgAHSpAWr6RW15kU6U9vwtQxIi/YBxQNcIA5RzQ46Fw==", "dev": true, "license": "MIT" }, @@ -5100,9 +5110,9 @@ "license": "Apache-2.0" }, "node_modules/electron-to-chromium": { - "version": "1.5.361", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", - "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "version": "1.5.366", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz", + "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==", "dev": true, "license": "ISC" }, @@ -5715,9 +5725,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -5988,10 +5998,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6624,9 +6644,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, "license": "MIT", "engines": { @@ -7054,9 +7074,9 @@ } }, "node_modules/protobufjs": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.2.tgz", - "integrity": "sha512-64rfNzkWOZAIazXzpBFPWq6F9up6gMvTzjE2oWIzApx2N/dqVUEE7+bCn2+40780dFVtKOUab8QfxJ6KJDWbqA==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.5.0.tgz", + "integrity": "sha512-df1jWDPA5VIBNRtuAHjqr09f2qN5D4Vke1wYqOQg1XJ7ZDpA7BD6L7E4tyChgGRLB5hqk2m79Zsy0WHwV9a84A==", "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -7123,9 +7143,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "dev": true, "license": "MIT", "engines": { @@ -7133,22 +7153,22 @@ } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.7" } }, "node_modules/react-intl": { - "version": "10.1.9", - "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-10.1.9.tgz", - "integrity": "sha512-FOWpTmwnZnAfz8JegRzyGnjUiuzLW3xJFjp/o8VR4HZAQ+Eg++YaeMhoUULLY+LMx7gZm3czaZEqcU4hntwerw==", + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-10.1.11.tgz", + "integrity": "sha512-9VCaWKumbrP91QvS50IYRlbZsD4KdSLwcfEeF9CQLmnqK9qCvFCfyof694I9lqHJL2/TUxBwLCA+whjF129oAg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7211,9 +7231,9 @@ } }, "node_modules/react-resizable-panels": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.1.tgz", - "integrity": "sha512-kA4w58V6wYdRLm2rg9pzroZwGlqBLul1FjMP0J8kqTo3zSHtjeH+LXmZaldCo6+HWqs1e5hOcPoajKXdOze37Q==", + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.2.tgz", + "integrity": "sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7337,13 +7357,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7353,31 +7373,31 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -7387,41 +7407,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", "fsevents": "~2.3.2" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7785,9 +7798,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -7795,9 +7808,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -7992,16 +8005,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", - "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.4", - "@typescript-eslint/parser": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4" + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8160,17 +8173,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -8303,19 +8316,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8343,12 +8356,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -8544,9 +8557,9 @@ } }, "node_modules/zustand": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", - "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index edc464d..f03350e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ioai/rosview", - "version": "1.3.5", + "version": "1.5.0", "description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA", "keywords": [ "ros", @@ -61,6 +61,7 @@ "typecheck": "tsc -b --noEmit", "prepublishOnly": "npm run lint && npm test && npm run build:lib", "lint": "eslint \"src/**/*.{ts,tsx}\" \"tests/**/*.ts\"", + "check:i18n": "node scripts/check-i18n.mjs", "test": "vitest run", "preview": "npm run build && vite preview", "gen:e2e:fixtures": "node scripts/gen-e2e-fixtures.mjs", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 0000000..e65f233 --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,252 @@ +/** + * Release gate: verify RosView i18n message shards stay in sync and that + * formatMessage ids referenced in source exist in the English catalog. + * + * Usage: node scripts/check-i18n.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const MESSAGES_DIR = path.join(ROOT, 'src/shared/intl/messages'); +const SRC_DIR = path.join(ROOT, 'src'); +const LOCALES = ['en', 'zh', 'ja']; + +/** @param {string} dir */ +function listJsonShards(locale) { + const dir = path.join(MESSAGES_DIR, locale); + return fs + .readdirSync(dir) + .filter((name) => name.endsWith('.json')) + .sort(); +} + +/** @param {string} locale @param {string} shard */ +function readShard(locale, shard) { + const filePath = path.join(MESSAGES_DIR, locale, shard); + return /** @type {Record} */ (JSON.parse(fs.readFileSync(filePath, 'utf8'))); +} + +/** @param {string} locale */ +function mergeLocale(locale) { + /** @type {Record} */ + const merged = {}; + const duplicates = []; + for (const shard of listJsonShards(locale)) { + const part = readShard(locale, shard); + for (const key of Object.keys(part)) { + if (Object.prototype.hasOwnProperty.call(merged, key)) { + duplicates.push({ locale, key, shard }); + } + merged[key] = part[key]; + } + } + return { merged, duplicates }; +} + +/** @param {string} dir @param {string[]} files */ +function walkSource(dir, files = []) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, ent.name); + if (ent.isDirectory()) { + if (!['node_modules', 'dist', 'dist-lib'].includes(ent.name)) { + walkSource(full, files); + } + continue; + } + if (/\.(ts|tsx)$/.test(ent.name) && !/\.(test|spec)\.(ts|tsx)$/.test(ent.name)) { + files.push(full); + } + } + return files; +} + +/** @param {string} content */ +function extractStaticMessageIds(content) { + /** @type {Set} */ + const ids = new Set(); + + const patterns = [ + /formatMessage\(\s*\{\s*id:\s*['"]([^'"]+)['"]/g, + /formatMessage\(\s*\n\s*\{\s*id:\s*['"]([^'"]+)['"]/g, + /offlineIntl\.formatMessage\(\s*\{\s*id:\s*['"]([^'"]+)['"]/g, + /intl\.formatMessage\(\s*\{\s*id:\s*['"]([^'"]+)['"]/g, + /(?:^|[?:,\s])['"]((?:panels|layout|navbar|quality|welcome|sidebar|playback|common|urdfDebug|errors|viewer)\.[^'"]+)['"]/gm, + ]; + + for (const re of patterns) { + re.lastIndex = 0; + let match; + while ((match = re.exec(content)) !== null) { + ids.add(match[1]); + } + } + + // Multiline formatMessage blocks. + for (const block of content.matchAll(/formatMessage\(\s*\{[\s\S]*?id:\s*['"]([^'"]+)['"]/g)) { + ids.add(block[1]); + } + + return ids; +} + +/** @param {string} content */ +function extractDynamicTemplatePrefixes(content) { + /** @type {Set} */ + const prefixes = new Set(); + const patterns = [ + /formatMessage\(\s*\{\s*id:\s*`([^`$]+)\$\{/g, + /formatMessage\(\s*\n\s*\{\s*id:\s*`([^`$]+)\$\{/g, + /offlineIntl\.formatMessage\(\s*\{\s*id:\s*`([^`$]+)\$\{/g, + ]; + for (const re of patterns) { + re.lastIndex = 0; + let match; + while ((match = re.exec(content)) !== null) { + prefixes.add(match[1]); + } + } + return prefixes; +} + +/** @returns {string[]} */ +function loadPanelTypeSlugs() { + const slugFile = path.join(ROOT, 'src/features/panels/framework/panelMessageSlug.ts'); + const content = fs.readFileSync(slugFile, 'utf8'); + const slugs = []; + for (const match of content.matchAll(/:\s*'([^']+)'/g)) { + slugs.push(match[1]); + } + return slugs; +} + +/** @param {string[]} issues */ +function fail(issues) { + console.error('\n[i18n] Check failed:\n'); + for (const issue of issues) { + console.error(` - ${issue}`); + } + process.exit(1); +} + +/** @param {string} message */ +function warn(message) { + console.warn(`[i18n] warn: ${message}`); +} + +const issues = []; + +// 1) Duplicate keys within each locale merge. +for (const locale of LOCALES) { + const { duplicates } = mergeLocale(locale); + for (const dup of duplicates) { + issues.push(`Duplicate key "${dup.key}" in locale "${dup.locale}" (shard ${dup.shard})`); + } +} + +// 2) Cross-locale parity per shard. +const shards = listJsonShards('en'); +for (const shard of shards) { + const enKeys = new Set(Object.keys(readShard('en', shard))); + for (const locale of ['zh', 'ja']) { + const locKeys = new Set(Object.keys(readShard(locale, shard))); + for (const key of enKeys) { + if (!locKeys.has(key)) { + issues.push(`Missing key "${key}" in ${locale}/${shard} (present in en)`); + } + } + for (const key of locKeys) { + if (!enKeys.has(key)) { + issues.push(`Extra key "${key}" in ${locale}/${shard} (not in en)`); + } + } + } +} + +const { merged: enMessages } = mergeLocale('en'); +const enKeys = new Set(Object.keys(enMessages)); + +// 3) Static ids referenced in source. +/** @type {Set} */ +const usedStaticIds = new Set(); +/** @type {Set} */ +const dynamicPrefixes = new Set(); + +for (const file of walkSource(SRC_DIR)) { + const content = fs.readFileSync(file, 'utf8'); + for (const id of extractStaticMessageIds(content)) { + usedStaticIds.add(id); + } + for (const prefix of extractDynamicTemplatePrefixes(content)) { + dynamicPrefixes.add(prefix); + } +} + +for (const id of usedStaticIds) { + if (!enKeys.has(id)) { + issues.push(`Message id "${id}" used in source but missing from en catalog`); + } +} + +// 4) Known dynamic expansions. +for (const lang of LOCALES) { + const id = `navbar.lang.${lang}`; + if (!enKeys.has(id)) { + issues.push(`Expected dynamic message id "${id}" missing from en catalog`); + } + usedStaticIds.add(id); +} + +for (const slug of loadPanelTypeSlugs()) { + const id = `panels.${slug}.defaultTitle`; + if (!enKeys.has(id)) { + issues.push(`Expected panel defaultTitle id "${id}" missing from en catalog`); + } + usedStaticIds.add(id); +} + +// Expand other dynamic template prefixes: every en key with that prefix must exist (parity already checked). +for (const prefix of dynamicPrefixes) { + const matching = [...enKeys].filter((key) => key.startsWith(prefix)); + if (matching.length === 0) { + issues.push(`Dynamic template prefix "${prefix}" has no matching keys in en catalog`); + } else { + for (const id of matching) { + usedStaticIds.add(id); + } + } +} + +// Context menu message ids (messageId field, not formatMessage). +const contextMenuIds = [ + 'layout.panelTab.context.close', + 'layout.panelTab.context.closeAllInGroup', + 'layout.panelTab.context.resetPanel', + 'layout.panelTab.context.copyPanelId', + 'layout.panelTab.context.duplicatePanel', +]; +for (const id of contextMenuIds) { + if (!enKeys.has(id)) { + issues.push(`Context menu message id "${id}" missing from en catalog`); + } + usedStaticIds.add(id); +} + +if (issues.length > 0) { + fail(issues); +} + +// 5) Optional summary. +const unusedCount = [...enKeys].filter((key) => !usedStaticIds.has(key)).length; +console.log('[i18n] OK'); +console.log(` locales: ${LOCALES.join(', ')}`); +console.log(` shards: ${shards.length} per locale`); +console.log(` total keys (en): ${enKeys.size}`); +console.log(` referenced keys (static + known dynamic): ${usedStaticIds.size}`); +console.log(` unused keys (en, informational): ${unusedCount}`); + +if (unusedCount > 0) { + warn(`${unusedCount} en keys are not referenced by the static scanner (may be dynamic or legacy)`); +} diff --git a/scripts/inspect-mcap-topics.mjs b/scripts/inspect-mcap-topics.mjs new file mode 100644 index 0000000..1340c3e --- /dev/null +++ b/scripts/inspect-mcap-topics.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * Inspect MCAP topics for Plot panel fixture generation. + * Usage: node scripts/inspect-mcap-topics.mjs path/to/file.mcap + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function main() { + const mcapPath = process.argv[2]; + if (!mcapPath) { + console.error('Usage: node scripts/inspect-mcap-topics.mjs '); + process.exit(1); + } + const resolved = path.resolve(mcapPath); + if (!fs.existsSync(resolved)) { + console.error('File not found:', resolved); + process.exit(1); + } + + const { McapIndexedReader } = await import('@mcap/core'); + const buffer = fs.readFileSync(resolved); + const reader = await McapIndexedReader.Initialize({ + readable: { + size: () => BigInt(buffer.byteLength), + read: async (offset, length) => buffer.subarray(offset, offset + length), + }, + }); + + const topics = new Map(); + for (const channel of reader.channelsById.values()) { + const schema = channel.schemaId !== 0 ? reader.schemasById.get(channel.schemaId) : undefined; + topics.set(channel.topic, { + topic: channel.topic, + schema: schema?.name ?? 'unknown', + messageEncoding: channel.messageEncoding, + }); + } + + console.log(JSON.stringify({ + file: resolved, + topicCount: topics.size, + topics: [...topics.values()].sort((a, b) => a.topic.localeCompare(b.topic)), + }, null, 2)); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/core/analysis/timeSeries.ts b/src/core/analysis/timeSeries.ts index 801dfd0..607c053 100644 --- a/src/core/analysis/timeSeries.ts +++ b/src/core/analysis/timeSeries.ts @@ -44,6 +44,50 @@ export function readHeaderStamp(message: unknown): Time | undefined { return undefined; } +/** True when a header stamp is present and plausibly within the log time window. */ +export function isHeaderStampInLogRange( + stamp: Time, + logStart: Time, + logEnd: Time, +): boolean { + const stampSec = timeToSec(stamp); + if (!Number.isFinite(stampSec)) return false; + const startSec = timeToSec(logStart); + const endSec = timeToSec(logEnd); + const span = Math.max(endSec - startSec, 1); + const margin = span * 0.05; + return stampSec >= startSec - margin && stampSec <= endSec + margin; +} + +/** + * Resolve the timestamp used for plotting. When header stamps fall outside the + * log window (common for unset `{sec:0,nsec:0}` stamps), fall back to receiveTime. + */ +export function resolvePlotEventTimestamp( + event: MessageEvent, + mode: TimestampMode, + logStart?: Time, + logEnd?: Time, +): TimestampResolution { + const resolution = resolveEventTimestamp(event, mode); + if ( + mode !== 'headerStamp' || + resolution.source !== 'headerStamp' || + logStart == null || + logEnd == null + ) { + return resolution; + } + if (isHeaderStampInLogRange(resolution.time, logStart, logEnd)) { + return resolution; + } + return { + time: event.receiveTime, + source: 'receiveTime', + fallbackReason: 'missingHeaderStamp', + }; +} + export function getEventTimestamp(event: MessageEvent, mode: TimestampMode): Time { if (mode === 'publishTime') { return event.publishTime ?? event.receiveTime; diff --git a/src/core/players/IterablePlayer.test.ts b/src/core/players/IterablePlayer.test.ts index e20fd7a..4ae2bbb 100644 --- a/src/core/players/IterablePlayer.test.ts +++ b/src/core/players/IterablePlayer.test.ts @@ -246,6 +246,107 @@ describe('IterablePlayer high-frequency lane', () => { }); }); +describe('IterablePlayer range reads', () => { + it('streams range messages in cursor batches', async () => { + const first = makeImageMessageAt(1); + const second = makeImageMessageAt(2); + const third = makeImageMessageAt(3); + const source = makeSource([]); + const cursor = { + nextBatch: vi + .fn() + .mockResolvedValueOnce([first, second]) + .mockResolvedValueOnce([third]) + .mockResolvedValueOnce([]), + end: vi.fn(async () => undefined), + }; + vi.mocked(source.getMessageCursor).mockResolvedValue(cursor as never); + const player = new IterablePlayer(source); + + try { + await player.initialize({}); + const batches: MessageEvent[][] = []; + for await (const batch of player.streamMessagesInTimeRange({ + start: { sec: 0, nsec: 0 }, + end: { sec: 5, nsec: 0 }, + topics: [TOPIC], + })) { + batches.push(batch); + } + + expect(batches).toEqual([[first, second], [third]]); + expect(source.getMessageCursor).toHaveBeenCalledWith({ + startTime: { sec: 0, nsec: 0 }, + endTime: { sec: 5, nsec: 0 }, + topics: [TOPIC], + }); + expect(cursor.end).toHaveBeenCalledTimes(1); + } finally { + player.close(); + } + }); + + it('keeps getMessagesInTimeRange compatible with streamed results', async () => { + const first = makeImageMessageAt(1); + const outside = makeImageMessageAt(9); + const second = makeImageMessageAt(2); + const source = makeSource([]); + const cursor = { + nextBatch: vi + .fn() + .mockResolvedValueOnce([first, outside]) + .mockResolvedValueOnce([second]) + .mockResolvedValueOnce([]), + end: vi.fn(async () => undefined), + }; + vi.mocked(source.getMessageCursor).mockResolvedValue(cursor as never); + const player = new IterablePlayer(source); + + try { + await player.initialize({}); + await expect(player.getMessagesInTimeRange({ + start: { sec: 0, nsec: 0 }, + end: { sec: 5, nsec: 0 }, + topics: [TOPIC], + })).resolves.toEqual([first, second]); + expect(cursor.end).toHaveBeenCalledTimes(1); + } finally { + player.close(); + } + }); + + it('stops streamed range reads at maxMessages', async () => { + const first = makeImageMessageAt(1); + const second = makeImageMessageAt(2); + const third = makeImageMessageAt(3); + const source = makeSource([]); + const cursor = { + nextBatch: vi.fn().mockResolvedValueOnce([first, second, third]), + end: vi.fn(async () => undefined), + }; + vi.mocked(source.getMessageCursor).mockResolvedValue(cursor as never); + const player = new IterablePlayer(source); + + try { + await player.initialize({}); + const batches: MessageEvent[][] = []; + for await (const batch of player.streamMessagesInTimeRange({ + start: { sec: 0, nsec: 0 }, + end: { sec: 5, nsec: 0 }, + topics: [TOPIC], + maxMessages: 2, + })) { + batches.push(batch); + } + + expect(batches).toEqual([[first, second]]); + expect(cursor.end).toHaveBeenCalledTimes(1); + } finally { + player.close(); + } + }); +}); + describe('IterablePlayer playback clock', () => { it('emits the current time immediately when subscribing', async () => { const source = makeSource([]); diff --git a/src/core/players/IterablePlayer.ts b/src/core/players/IterablePlayer.ts index ee918b5..fed2fcc 100644 --- a/src/core/players/IterablePlayer.ts +++ b/src/core/players/IterablePlayer.ts @@ -3,6 +3,7 @@ import type { HighFrequencyConsumer, Player, PlayerState, + StreamMessagesInTimeRangeArgs, Subscription, } from '@/core/types/player'; import { PLAYBACK_SPEED_MAX } from '@/core/types/player'; @@ -26,6 +27,9 @@ const BACKFILL_STALE_THRESHOLD_NS = 1_000_000_000n; const BACKFILL_STALE_TOPIC_PERIOD_MULTIPLIER = 3; const STALE_TOPIC_REFRESH_COOLDOWN_MS = 500; const SLOW_DISTRIBUTION_MS = 16; +const RANGE_READ_MAX_MESSAGES = 80_000; +const RANGE_READ_BATCH_MESSAGES = 2048; +const RANGE_READ_BATCH_WALL_MS = 16; function isSameRanges(nextValue?: Range[], prevValue?: Range[]): boolean { if (nextValue === prevValue) { @@ -413,6 +417,7 @@ export class IterablePlayer implements Player { isLooping: this._isLooping, speed: this._speed, problems: this._initialization.problems, + randomAccessByTopic: this._initialization.randomAccessByTopic, }; messageBus.reset(); @@ -1305,21 +1310,20 @@ export class IterablePlayer implements Player { } /** - * Independent range scan for diagnostics (Align). Uses a short-lived cursor; - * does not replace the playback cursor. Drains the iterator until an empty - * batch; partial batches can still have more messages (MessageCursor caps batch size). + * Independent range scan for diagnostics and panels. Uses a short-lived cursor; + * does not replace the playback cursor. Batches are yielded as soon as the + * worker cursor produces them so callers can render partial results. */ - async getMessagesInTimeRange(args: GetMessagesInTimeRangeArgs): Promise { - const MAX = 80_000; + async *streamMessagesInTimeRange(args: StreamMessagesInTimeRangeArgs): AsyncIterable { if (!this._initialization || args.topics.length === 0) { - return []; + return; } const start = this._clampToRange(args.start); const end = this._clampToRange(args.end); const startNs = toNano(start); const endNs = toNano(end); if (startNs > endNs) { - return []; + return; } const cursor = await this._source.getMessageCursor({ @@ -1327,34 +1331,63 @@ export class IterablePlayer implements Player { endTime: end, topics: args.topics, }); - const out: MessageEvent[] = []; + const maxMessages = + typeof args.maxMessages === "number" && Number.isFinite(args.maxMessages) && args.maxMessages > 0 + ? Math.floor(args.maxMessages) + : RANGE_READ_MAX_MESSAGES; + const batchSize = + typeof args.batchSize === "number" && Number.isFinite(args.batchSize) && args.batchSize > 0 + ? Math.floor(args.batchSize) + : RANGE_READ_BATCH_MESSAGES; + const batchWallTimeMs = + typeof args.batchWallTimeMs === "number" && Number.isFinite(args.batchWallTimeMs) && args.batchWallTimeMs > 0 + ? Math.floor(args.batchWallTimeMs) + : RANGE_READ_BATCH_WALL_MS; + let emitted = 0; try { for (;;) { - if (out.length >= MAX) { + if (emitted >= maxMessages) { break; } const batch = await cursor.nextBatch(86_400_000, { - maxMessages: 2048, - maxWallTimeMs: 16, + maxMessages: Math.min(batchSize, maxMessages - emitted), + maxWallTimeMs: batchWallTimeMs, }); if (batch.length === 0) { break; } const resolved = this._source.resolveMessageBatch(batch); + const out: MessageEvent[] = []; for (const m of resolved) { const t = toNano(m.receiveTime); if (t < startNs || t > endNs) { continue; } out.push(m); - if (out.length >= MAX) { + emitted++; + if (emitted >= maxMessages) { break; } } + if (out.length > 0) { + yield out; + } } } finally { await cursor.end(); } + } + + /** + * Independent range scan for diagnostics (Align). Uses a short-lived cursor; + * does not replace the playback cursor. Drains the iterator until an empty + * batch; partial batches can still have more messages (MessageCursor caps batch size). + */ + async getMessagesInTimeRange(args: GetMessagesInTimeRangeArgs): Promise { + const out: MessageEvent[] = []; + for await (const batch of this.streamMessagesInTimeRange(args)) { + out.push(...batch); + } return out; } diff --git a/src/core/preferences/foxgloveLayout.ts b/src/core/preferences/foxgloveLayout.ts index 601f8f2..5c33053 100644 --- a/src/core/preferences/foxgloveLayout.ts +++ b/src/core/preferences/foxgloveLayout.ts @@ -621,6 +621,16 @@ export function importFoxgloveLayout( extras: decoded.extras, }; restored += 1; + if ( + adapter.internalType === 'JointStatePlot' + || foxgloveType === 'JointStatePlot' + || foxgloveType === 'Joints' + ) { + console.warn( + `[rosview] Layout panel "${panelId}" uses deprecated JointStatePlot and will stop working in a future version. ` + + 'Please migrate to the Plot panel for joint state visualization.', + ); + } serializedPanels[panelId] = { id: panelId, contentComponent: adapter.internalType, diff --git a/src/core/types/player.ts b/src/core/types/player.ts index 7e63893..8d0daae 100644 --- a/src/core/types/player.ts +++ b/src/core/types/player.ts @@ -8,6 +8,12 @@ export interface GetMessagesInTimeRangeArgs { topics: string[]; } +export interface StreamMessagesInTimeRangeArgs extends GetMessagesInTimeRangeArgs { + maxMessages?: number; + batchSize?: number; + batchWallTimeMs?: number; +} + export type Unsubscribe = () => void; /** Random-access byte reader for ROS bag-like sources. */ @@ -73,6 +79,8 @@ export interface PlayerState { isLooping: boolean; speed: number; problems: PlayerProblem[]; + /** Whether the source supports efficient per-topic random access reads. */ + randomAccessByTopic?: boolean; }; } @@ -115,6 +123,11 @@ export interface Player { * Implemented by {@link IterablePlayer}; absent on other player stubs. */ getMessagesInTimeRange?(args: GetMessagesInTimeRangeArgs): Promise; + /** + * Stream deserialized messages in `[start, end]` by receive time. Batches are yielded as soon + * as the source cursor produces them so callers can render partial results. + */ + streamMessagesInTimeRange?(args: StreamMessagesInTimeRangeArgs): AsyncIterable; startDataQualityScan?(): void; setSpeed(speed: number): void; setSamplingFps(fps: number): void; diff --git a/src/core/types/ros.ts b/src/core/types/ros.ts index 1a17678..63b9be5 100644 --- a/src/core/types/ros.ts +++ b/src/core/types/ros.ts @@ -66,6 +66,12 @@ export interface Initialization { * typically leave it unset. */ preferredSamplingFps?: number; + /** + * When true, the source supports efficient random access by topic and time + * (e.g. MCAP chunk index, SQLite db3). Plot panels can read only subscribed + * topics without scanning the full recording. + */ + randomAccessByTopic?: boolean; } export interface MessageEvent { diff --git a/src/entrypoints/urdf-preview.ts b/src/entrypoints/urdf-preview.ts index c50a2b0..d9f11b4 100644 --- a/src/entrypoints/urdf-preview.ts +++ b/src/entrypoints/urdf-preview.ts @@ -22,4 +22,4 @@ export { } from '../features/panels/UrdfDebug/urdfAnalysis'; export { extractPackageNameFromUrdf } from '../features/panels/UrdfDebug/meshBaseStatus'; -export type { JointStateMsg } from '../features/panels/ThreeD/foxglove-core/types'; +export type { JointStateMsg } from '../features/panels/ThreeD/core/types'; diff --git a/src/features/layout/PanelTabAddPanelDefinitionsSubmenus.tsx b/src/features/layout/PanelTabAddPanelDefinitionsSubmenus.tsx index ff352ca..3437dd7 100644 --- a/src/features/layout/PanelTabAddPanelDefinitionsSubmenus.tsx +++ b/src/features/layout/PanelTabAddPanelDefinitionsSubmenus.tsx @@ -8,6 +8,7 @@ import { DropdownMenuSubTrigger, } from '@/shared/ui/dropdown-menu'; import type { PanelDefinition, PanelType } from '../panels/framework'; +import { PANEL_TYPE_MESSAGE_SLUG } from '../panels/framework/panelMessageSlug'; import { PanelTypeIcon } from '../panels/framework/panelIcons'; /** Shared row style for tab header dropdown items with a leading icon. */ @@ -31,7 +32,10 @@ export const PanelTabAddPanelDefinitionsSubmenus: React.FC - {def.defaultTitle} + {formatMessage({ + id: `panels.${PANEL_TYPE_MESSAGE_SLUG[def.type]}.defaultTitle`, + defaultMessage: def.defaultTitle, + })} = ({ api, conta const useCompactTabActions = tabWidth === undefined ? true : tabWidth < PANEL_TAB_EXPANDED_MIN_WIDTH_PX; const definitions = useMemo( - () => getPanelDefinitions().filter((d) => d.type !== 'Unavailable'), + () => getAddablePanelDefinitions(), [], ); const [ctx, setCtx] = useState<{ x: number; y: number } | null>(null); diff --git a/src/features/layout/WelcomePanelContent.tsx b/src/features/layout/WelcomePanelContent.tsx index 6749519..ab13a3e 100644 --- a/src/features/layout/WelcomePanelContent.tsx +++ b/src/features/layout/WelcomePanelContent.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; -import { getPanelDefinitions } from '../panels/registry'; +import { getAddablePanelDefinitions } from '../panels/registry'; import { PanelTypeIcon } from '../panels/framework/panelIcons'; import { PANEL_TYPE_MESSAGE_SLUG } from '../panels/framework/panelMessageSlug'; import type { PanelType } from '../panels/framework/types'; @@ -10,6 +10,7 @@ import { getDockviewApi } from './dockviewGlobalApi'; /** Message id for the one-line blurb on each panel card (`layout.welcomePanel.desc.*`). */ const PANEL_DESCRIPTION_IDS: Partial> = { Image: 'layout.welcomePanel.desc.Image', + Plot: 'layout.welcomePanel.desc.Plot', JointStatePlot: 'layout.welcomePanel.desc.JointStatePlot', '3D': 'layout.welcomePanel.desc.3D', Audio: 'layout.welcomePanel.desc.Audio', @@ -27,7 +28,7 @@ interface WelcomePanelContentProps { export const WelcomePanelContent: React.FC = ({ welcomePanelId }) => { const { formatMessage } = useIntl(); const definitions = useMemo( - () => getPanelDefinitions().filter((d) => d.type !== 'Unavailable'), + () => getAddablePanelDefinitions(), [], ); diff --git a/src/features/layout/autoLayout/applyDefaultRosDockLayout.ts b/src/features/layout/autoLayout/applyDefaultRosDockLayout.ts index b645ce4..95dd887 100644 --- a/src/features/layout/autoLayout/applyDefaultRosDockLayout.ts +++ b/src/features/layout/autoLayout/applyDefaultRosDockLayout.ts @@ -16,7 +16,7 @@ import { type FoxgloveMosaicNode, } from '@/core/preferences/foxgloveLayout'; import { planColorDepthCameraRows } from '@/features/layout/autoLayout/planRosImageGrid'; -import { heuristicAudioInfoTopics } from '@/features/panels/Audio/audio-core/resolveAudioInfo'; +import { heuristicAudioInfoTopics } from '@/features/panels/Audio/core/resolveAudioInfo'; import { getPanelDefinition } from '@/features/panels/registry'; import { isAudioCommonInfoSchema, isJointStateSchema, isRawAudioSchema, normalizeRosSchemaName } from '@/shared/ros/rosMessageTypes'; import { pickDefaultRawMessagesTopic } from '@/features/layout/autoLayout/pickDefaultRawMessagesTopic'; diff --git a/src/features/layout/rosviewTabContextMenu.tsx b/src/features/layout/rosviewTabContextMenu.tsx index 7610f6e..57ec1b9 100644 --- a/src/features/layout/rosviewTabContextMenu.tsx +++ b/src/features/layout/rosviewTabContextMenu.tsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react'; import { createPortal } from 'react-dom'; +import { useIntl } from 'react-intl'; import type { DockviewApi, DockviewPanelApi } from 'dockview'; import { cn } from '@/shared/lib/utils'; import { getRosViewPortalRoot } from '@/shared/lib/rosviewPortal'; @@ -7,7 +8,7 @@ import { getPanelActions } from '../panels/framework'; export interface TabContextMenuItem { id: string; - label: string; + messageId?: string; onSelect: () => void; destructive?: boolean; disabled?: boolean; @@ -36,10 +37,15 @@ export function buildRosViewTabContextMenuItems(options: { }; const items: TabContextMenuItem[] = [ - { id: 'close', label: 'Close', onSelect: closeTarget, destructive: true }, + { + id: 'close', + messageId: 'layout.panelTab.context.close', + onSelect: closeTarget, + destructive: true, + }, { id: 'closeAll', - label: 'Close all in group', + messageId: 'layout.panelTab.context.closeAllInGroup', onSelect: closeAll, disabled: groupPanels.length === 0, }, @@ -50,10 +56,22 @@ export function buildRosViewTabContextMenuItems(options: { if (actions && !isWelcome) { items.push( - { id: '__sep__', label: '', onSelect: () => {} }, - { id: 'reset', label: 'Reset panel', onSelect: actions.resetPanel }, - { id: 'copy', label: 'Copy panel ID', onSelect: actions.copyPanelId }, - { id: 'dup', label: 'Duplicate panel', onSelect: actions.duplicatePanel }, + { id: '__sep__', onSelect: () => {} }, + { + id: 'reset', + messageId: 'layout.panelTab.context.resetPanel', + onSelect: actions.resetPanel, + }, + { + id: 'copy', + messageId: 'layout.panelTab.context.copyPanelId', + onSelect: actions.copyPanelId, + }, + { + id: 'dup', + messageId: 'layout.panelTab.context.duplicatePanel', + onSelect: actions.duplicatePanel, + }, ); } @@ -65,6 +83,8 @@ export const RosViewTabContextMenuPortal: React.FC<{ items: TabContextMenuItem[]; onRequestClose: () => void; }> = ({ anchor, items, onRequestClose }) => { + const { formatMessage } = useIntl(); + useEffect(() => { if (!anchor) { return; @@ -126,7 +146,7 @@ export const RosViewTabContextMenuPortal: React.FC<{ } }} > - {item.label} + {item.messageId ? formatMessage({ id: item.messageId }) : null} ), )} diff --git a/src/features/panels/Align/Component.tsx b/src/features/panels/Align/AlignPanel.tsx similarity index 99% rename from src/features/panels/Align/Component.tsx rename to src/features/panels/Align/AlignPanel.tsx index d3396ee..27868dc 100644 --- a/src/features/panels/Align/Component.tsx +++ b/src/features/panels/Align/AlignPanel.tsx @@ -15,7 +15,7 @@ import { formatOffsetMs, messageToAlignPoint, type AlignPoint, -} from '../align-core/alignTimeUtils'; +} from './core/alignTimeUtils'; import type { AlignConfig } from './defaults'; const MAX_POINTS = 60_000; diff --git a/src/features/panels/Align/AlignPanelSettings.tsx b/src/features/panels/Align/AlignPanelSettings.tsx index 2d312af..4bab286 100644 --- a/src/features/panels/Align/AlignPanelSettings.tsx +++ b/src/features/panels/Align/AlignPanelSettings.tsx @@ -8,7 +8,7 @@ import { SettingsTextArea, } from '../framework/settings'; import type { AlignConfig } from './defaults'; -import type { AlignPlotTimeMode } from '../align-core/alignTimeUtils'; +import type { AlignPlotTimeMode } from './core/alignTimeUtils'; export function AlignPanelSettings({ config, diff --git a/src/features/panels/align-core/alignTimeUtils.test.ts b/src/features/panels/Align/core/alignTimeUtils.test.ts similarity index 100% rename from src/features/panels/align-core/alignTimeUtils.test.ts rename to src/features/panels/Align/core/alignTimeUtils.test.ts diff --git a/src/features/panels/align-core/alignTimeUtils.ts b/src/features/panels/Align/core/alignTimeUtils.ts similarity index 100% rename from src/features/panels/align-core/alignTimeUtils.ts rename to src/features/panels/Align/core/alignTimeUtils.ts diff --git a/src/features/panels/Align/defaults.ts b/src/features/panels/Align/defaults.ts index e30630d..69ea039 100644 --- a/src/features/panels/Align/defaults.ts +++ b/src/features/panels/Align/defaults.ts @@ -1,4 +1,4 @@ -import type { AlignPlotTimeMode } from '../align-core/alignTimeUtils'; +import type { AlignPlotTimeMode } from './core/alignTimeUtils'; export interface AlignConfig { /** Empty ⇒ all image topics from the dataset. */ diff --git a/src/features/panels/Align/definition.tsx b/src/features/panels/Align/definition.tsx index 691feb3..37f7904 100644 --- a/src/features/panels/Align/definition.tsx +++ b/src/features/panels/Align/definition.tsx @@ -6,7 +6,7 @@ import { parseAlignConfig } from './schema'; import { AlignPanelSettings } from './AlignPanelSettings'; const AlignPanel = lazy(async () => { - const m = await import('./Component'); + const m = await import('./AlignPanel'); return { default: m.AlignPanel }; }); diff --git a/src/features/panels/Align/schema.ts b/src/features/panels/Align/schema.ts index 002b80f..b14aa1d 100644 --- a/src/features/panels/Align/schema.ts +++ b/src/features/panels/Align/schema.ts @@ -1,6 +1,6 @@ import { isRecord } from '../framework/types'; import { defaultAlignConfig, type AlignConfig } from './defaults'; -import type { AlignPlotTimeMode } from '../align-core/alignTimeUtils'; +import type { AlignPlotTimeMode } from './core/alignTimeUtils'; function clampNumber(value: unknown, fallback: number, min: number, max: number): number { if (typeof value !== 'number' || !Number.isFinite(value)) { diff --git a/src/features/panels/Audio/Component.tsx b/src/features/panels/Audio/AudioPanel.tsx similarity index 97% rename from src/features/panels/Audio/Component.tsx rename to src/features/panels/Audio/AudioPanel.tsx index e35c75d..94e57da 100644 --- a/src/features/panels/Audio/Component.tsx +++ b/src/features/panels/Audio/AudioPanel.tsx @@ -16,11 +16,11 @@ import { } from '@/shared/ros/rosMessageTypes'; import { TopicQuickPicker } from '../framework/TopicQuickPicker'; import type { AudioConfig } from './defaults'; -import { AudioPlaybackController } from './audio-core/audioPlaybackController'; -import { normalizeAudioMessage } from './audio-core/normalize'; -import { heuristicAudioInfoTopics, ingestAudioInfoFromEvent } from './audio-core/resolveAudioInfo'; -import type { ParsedAudioInfo } from './audio-core/types'; -import { WaveformEnvelopeBuffer } from './audio-core/waveformBuffer'; +import { AudioPlaybackController } from './core/audioPlaybackController'; +import { normalizeAudioMessage } from './core/normalize'; +import { heuristicAudioInfoTopics, ingestAudioInfoFromEvent } from './core/resolveAudioInfo'; +import type { ParsedAudioInfo } from './core/types'; +import { WaveformEnvelopeBuffer } from './core/waveformBuffer'; export type AudioPanelProps = AudioConfig & { player: Player; diff --git a/src/features/panels/Audio/audio-core/audioPlaybackController.ts b/src/features/panels/Audio/core/audioPlaybackController.ts similarity index 100% rename from src/features/panels/Audio/audio-core/audioPlaybackController.ts rename to src/features/panels/Audio/core/audioPlaybackController.ts diff --git a/src/features/panels/Audio/audio-core/normalize.test.ts b/src/features/panels/Audio/core/normalize.test.ts similarity index 100% rename from src/features/panels/Audio/audio-core/normalize.test.ts rename to src/features/panels/Audio/core/normalize.test.ts diff --git a/src/features/panels/Audio/audio-core/normalize.ts b/src/features/panels/Audio/core/normalize.ts similarity index 100% rename from src/features/panels/Audio/audio-core/normalize.ts rename to src/features/panels/Audio/core/normalize.ts diff --git a/src/features/panels/Audio/audio-core/parseAudioInfo.ts b/src/features/panels/Audio/core/parseAudioInfo.ts similarity index 100% rename from src/features/panels/Audio/audio-core/parseAudioInfo.ts rename to src/features/panels/Audio/core/parseAudioInfo.ts diff --git a/src/features/panels/Audio/audio-core/pcmConvert.ts b/src/features/panels/Audio/core/pcmConvert.ts similarity index 100% rename from src/features/panels/Audio/audio-core/pcmConvert.ts rename to src/features/panels/Audio/core/pcmConvert.ts diff --git a/src/features/panels/Audio/audio-core/resolveAudioInfo.ts b/src/features/panels/Audio/core/resolveAudioInfo.ts similarity index 100% rename from src/features/panels/Audio/audio-core/resolveAudioInfo.ts rename to src/features/panels/Audio/core/resolveAudioInfo.ts diff --git a/src/features/panels/Audio/audio-core/types.ts b/src/features/panels/Audio/core/types.ts similarity index 100% rename from src/features/panels/Audio/audio-core/types.ts rename to src/features/panels/Audio/core/types.ts diff --git a/src/features/panels/Audio/audio-core/waveformBuffer.ts b/src/features/panels/Audio/core/waveformBuffer.ts similarity index 100% rename from src/features/panels/Audio/audio-core/waveformBuffer.ts rename to src/features/panels/Audio/core/waveformBuffer.ts diff --git a/src/features/panels/Audio/definition.tsx b/src/features/panels/Audio/definition.tsx index faec8e1..93b7937 100644 --- a/src/features/panels/Audio/definition.tsx +++ b/src/features/panels/Audio/definition.tsx @@ -11,7 +11,7 @@ import { parseAudioConfig } from './schema'; import { AudioPanelSettings } from './AudioPanelSettings'; const AudioPanel = lazy(async () => { - const m = await import('./Component'); + const m = await import('./AudioPanel'); return { default: m.AudioPanel }; }); diff --git a/src/features/panels/Image/Component.tsx b/src/features/panels/Image/ImagePanel.tsx similarity index 96% rename from src/features/panels/Image/Component.tsx rename to src/features/panels/Image/ImagePanel.tsx index 8d8a242..b0be675 100644 --- a/src/features/panels/Image/Component.tsx +++ b/src/features/panels/Image/ImagePanel.tsx @@ -4,21 +4,21 @@ import type { Player } from '@/core/types/player'; import type { MessageEvent as RosMessageEvent } from '@/core/types/ros'; import { scheduleFrame } from '@/shared/utils/rafScheduler'; import { toNano } from '@/shared/utils/time'; -import type { RawImageDecodeOptions } from './image-core/imageColorMode'; +import type { RawImageDecodeOptions } from './core/imageColorMode'; import type { ImageRenderOptions, ImageRenderWorkerEvent, ImageRenderWorkerRequest, -} from './image-core/imageWorkerProtocol'; +} from './core/imageWorkerProtocol'; import { IMAGE_PANEL_TOPIC_INCLUDES, type ImageSurfaceStatus, -} from './image-core/imageTypes'; -import { repairH264Seek } from './image-core/h264SeekRepair'; -import { isH264MessageEvent, toWorkerFrame } from './image-core/messageFrameAdapter'; +} from './core/imageTypes'; +import { repairH264Seek } from './core/h264SeekRepair'; +import { isH264MessageEvent, toWorkerFrame } from './core/messageFrameAdapter'; import type { ImageConfig } from './defaults'; import { TopicQuickPicker } from '../framework/TopicQuickPicker'; -import ImageRenderWorkerClass from './image-core/ImageRender.worker.ts?worker&inline'; +import ImageRenderWorkerClass from './core/ImageRender.worker.ts?worker&inline'; type ColorOptions = Pick; diff --git a/src/features/panels/Image/ImagePanelSettings.tsx b/src/features/panels/Image/ImagePanelSettings.tsx index 2706998..d9026e1 100644 --- a/src/features/panels/Image/ImagePanelSettings.tsx +++ b/src/features/panels/Image/ImagePanelSettings.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { useIntl } from 'react-intl'; -import type { ImageColorMode } from './image-core/imageColorMode'; +import type { ImageColorMode } from './core/imageColorMode'; import type { PanelSettingsContext } from '../framework/types'; import { SettingsField, @@ -13,7 +13,7 @@ import { } from '../framework/settings'; import { messageBus } from '@/core/pipeline/messageBus'; import { useTopicSeq } from '@/core/pipeline/useMessageBus'; -import { isRawImageMessage, isRawImageTopicSchema, IMAGE_PANEL_TOPIC_INCLUDES } from './image-core/imageTypes'; +import { isRawImageMessage, isRawImageTopicSchema, IMAGE_PANEL_TOPIC_INCLUDES } from './core/imageTypes'; import type { ImageConfig } from './defaults'; const DEPTH_ENCODINGS = new Set(['mono16', '16uc1', '32fc1']); diff --git a/src/features/panels/Image/image-core/ImageRender.worker.ts b/src/features/panels/Image/core/ImageRender.worker.ts similarity index 100% rename from src/features/panels/Image/image-core/ImageRender.worker.ts rename to src/features/panels/Image/core/ImageRender.worker.ts diff --git a/src/features/panels/Image/image-core/asyncTimeout.test.ts b/src/features/panels/Image/core/asyncTimeout.test.ts similarity index 100% rename from src/features/panels/Image/image-core/asyncTimeout.test.ts rename to src/features/panels/Image/core/asyncTimeout.test.ts diff --git a/src/features/panels/Image/image-core/asyncTimeout.ts b/src/features/panels/Image/core/asyncTimeout.ts similarity index 100% rename from src/features/panels/Image/image-core/asyncTimeout.ts rename to src/features/panels/Image/core/asyncTimeout.ts diff --git a/src/features/panels/Image/image-core/h264.test.ts b/src/features/panels/Image/core/h264.test.ts similarity index 100% rename from src/features/panels/Image/image-core/h264.test.ts rename to src/features/panels/Image/core/h264.test.ts diff --git a/src/features/panels/Image/image-core/h264.ts b/src/features/panels/Image/core/h264.ts similarity index 100% rename from src/features/panels/Image/image-core/h264.ts rename to src/features/panels/Image/core/h264.ts diff --git a/src/features/panels/Image/image-core/h264SeekRepair.test.ts b/src/features/panels/Image/core/h264SeekRepair.test.ts similarity index 100% rename from src/features/panels/Image/image-core/h264SeekRepair.test.ts rename to src/features/panels/Image/core/h264SeekRepair.test.ts diff --git a/src/features/panels/Image/image-core/h264SeekRepair.ts b/src/features/panels/Image/core/h264SeekRepair.ts similarity index 100% rename from src/features/panels/Image/image-core/h264SeekRepair.ts rename to src/features/panels/Image/core/h264SeekRepair.ts diff --git a/src/features/panels/Image/image-core/imageColorMode.ts b/src/features/panels/Image/core/imageColorMode.ts similarity index 100% rename from src/features/panels/Image/image-core/imageColorMode.ts rename to src/features/panels/Image/core/imageColorMode.ts diff --git a/src/features/panels/Image/image-core/imageTypes.test.ts b/src/features/panels/Image/core/imageTypes.test.ts similarity index 100% rename from src/features/panels/Image/image-core/imageTypes.test.ts rename to src/features/panels/Image/core/imageTypes.test.ts diff --git a/src/features/panels/Image/image-core/imageTypes.ts b/src/features/panels/Image/core/imageTypes.ts similarity index 100% rename from src/features/panels/Image/image-core/imageTypes.ts rename to src/features/panels/Image/core/imageTypes.ts diff --git a/src/features/panels/Image/image-core/imageWorkerProtocol.ts b/src/features/panels/Image/core/imageWorkerProtocol.ts similarity index 100% rename from src/features/panels/Image/image-core/imageWorkerProtocol.ts rename to src/features/panels/Image/core/imageWorkerProtocol.ts diff --git a/src/features/panels/Image/image-core/messageFrameAdapter.test.ts b/src/features/panels/Image/core/messageFrameAdapter.test.ts similarity index 100% rename from src/features/panels/Image/image-core/messageFrameAdapter.test.ts rename to src/features/panels/Image/core/messageFrameAdapter.test.ts diff --git a/src/features/panels/Image/image-core/messageFrameAdapter.ts b/src/features/panels/Image/core/messageFrameAdapter.ts similarity index 100% rename from src/features/panels/Image/image-core/messageFrameAdapter.ts rename to src/features/panels/Image/core/messageFrameAdapter.ts diff --git a/src/features/panels/Image/image-core/rawDecoders.test.ts b/src/features/panels/Image/core/rawDecoders.test.ts similarity index 100% rename from src/features/panels/Image/image-core/rawDecoders.test.ts rename to src/features/panels/Image/core/rawDecoders.test.ts diff --git a/src/features/panels/Image/image-core/rawDecoders.ts b/src/features/panels/Image/core/rawDecoders.ts similarity index 100% rename from src/features/panels/Image/image-core/rawDecoders.ts rename to src/features/panels/Image/core/rawDecoders.ts diff --git a/src/features/panels/Image/defaults.ts b/src/features/panels/Image/defaults.ts index d712abf..f6444ef 100644 --- a/src/features/panels/Image/defaults.ts +++ b/src/features/panels/Image/defaults.ts @@ -1,4 +1,4 @@ -import type { ImageColorMode } from './image-core/imageColorMode'; +import type { ImageColorMode } from './core/imageColorMode'; export interface ImageConfig { topic: string; diff --git a/src/features/panels/Image/definition.tsx b/src/features/panels/Image/definition.tsx index 2875cd9..140abcc 100644 --- a/src/features/panels/Image/definition.tsx +++ b/src/features/panels/Image/definition.tsx @@ -11,7 +11,7 @@ import { import { ImagePanelSettings } from './ImagePanelSettings'; const ImagePanel = lazy(async () => { - const m = await import('./Component'); + const m = await import('./ImagePanel'); return { default: m.ImagePanel }; }); diff --git a/src/features/panels/Image/schema.ts b/src/features/panels/Image/schema.ts index e06be23..4c0a872 100644 --- a/src/features/panels/Image/schema.ts +++ b/src/features/panels/Image/schema.ts @@ -1,4 +1,4 @@ -import type { ImageColorMode } from './image-core/imageColorMode'; +import type { ImageColorMode } from './core/imageColorMode'; import { isRecord } from '../framework/types'; import { defaultImageConfig, type ImageConfig } from './defaults'; diff --git a/src/features/panels/JointStatePlot/Component.tsx b/src/features/panels/JointStatePlot/JointStatePlotPanel.tsx similarity index 100% rename from src/features/panels/JointStatePlot/Component.tsx rename to src/features/panels/JointStatePlot/JointStatePlotPanel.tsx diff --git a/src/features/panels/JointStatePlot/definition.tsx b/src/features/panels/JointStatePlot/definition.tsx index 95e2e17..ac9c8df 100644 --- a/src/features/panels/JointStatePlot/definition.tsx +++ b/src/features/panels/JointStatePlot/definition.tsx @@ -28,7 +28,7 @@ import { parseJointStatePlotConfig } from './schema'; import { JointStatePlotPanelSettings } from './JointStatePlotPanelSettings'; const JointStatePlotComponent = lazy(async () => { - const m = await import('./Component'); + const m = await import('./JointStatePlotPanel'); return { default: m.JointStatePlotComponent }; }); @@ -269,6 +269,7 @@ const JointStatePanelWrapper: React.FC = ({ export const jointStatePlotDefinition: PanelDefinition = { type: 'JointStatePlot', + hideFromPanelPicker: true, defaultTitle: 'JointState Plot', createDefaultConfig: defaultJointStatePlotConfig, configSchema: { version: 1, parse: parseJointStatePlotConfig }, diff --git a/src/features/panels/Plot/PlotLegendSettings.tsx b/src/features/panels/Plot/PlotLegendSettings.tsx new file mode 100644 index 0000000..a673f86 --- /dev/null +++ b/src/features/panels/Plot/PlotLegendSettings.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { useIntl } from 'react-intl'; +import { SettingsSection } from '../framework/settings/SettingsPrimitives'; +import { ScrollArea } from '@/shared/ui/scroll-area'; +import type { PlotConfig } from './defaults'; +import { + isPlotLegendVisible, + plotLegendSelectionState, + setAllPlotLegendVisible, + setPlotLegendVisible, +} from './plotLegendVisibility'; +import { usePlotLegendEntries } from './plotPanelRuntimeStore'; + +interface PlotLegendSettingsProps { + panelId: string; + config: PlotConfig; + setConfig: (next: PlotConfig | ((prev: PlotConfig) => PlotConfig)) => void; +} + +function legendInputId(panelId: string, index: number): string { + return `plot-legend-${panelId}-${index}`; +} + +export function PlotLegendSettings({ + panelId, + config, + setConfig, +}: PlotLegendSettingsProps): React.ReactNode { + const { formatMessage } = useIntl(); + const entries = usePlotLegendEntries(panelId); + const hiddenKeys = config.hiddenLegendKeys; + const selectAllRef = useRef(null); + const selectAllId = `plot-legend-${panelId}-all`; + + const selection = useMemo( + () => plotLegendSelectionState(entries, hiddenKeys), + [entries, hiddenKeys], + ); + + const visibleCount = useMemo( + () => entries.filter((entry) => isPlotLegendVisible(hiddenKeys, entry.key)).length, + [entries, hiddenKeys], + ); + + useEffect(() => { + const input = selectAllRef.current; + if (!input) return; + input.indeterminate = selection === 'partial'; + }, [selection]); + + const setHiddenKeys = (next: string[]) => { + setConfig((prev) => ({ ...prev, hiddenLegendKeys: next })); + }; + + const toggleEntry = (key: string, visible: boolean) => { + setHiddenKeys(setPlotLegendVisible(hiddenKeys, key, visible)); + }; + + const toggleAll = (visible: boolean) => { + setHiddenKeys(setAllPlotLegendVisible(entries.map((entry) => entry.key), visible)); + }; + + return ( + + {entries.length === 0 ? ( +

+ {formatMessage({ id: 'panels.plot.settings.legend.empty' })} +

+ ) : ( + <> +
+ toggleAll(event.target.checked)} + className="h-3.5 w-3.5 shrink-0 accent-primary" + aria-label={formatMessage({ id: 'panels.plot.settings.legend.selectAllAria' })} + /> + +
+ +
+ {entries.map((entry, index) => { + const inputId = legendInputId(panelId, index); + const visible = isPlotLegendVisible(hiddenKeys, entry.key); + return ( + + ); + })} +
+
+ + )} +
+ ); +} diff --git a/src/features/panels/Plot/PlotPanel.tsx b/src/features/panels/Plot/PlotPanel.tsx new file mode 100644 index 0000000..aba5931 --- /dev/null +++ b/src/features/panels/Plot/PlotPanel.tsx @@ -0,0 +1,301 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { useIntl } from 'react-intl'; +import { useShallow } from 'zustand/react/shallow'; +import 'uplot/dist/uPlot.min.css'; +import type { MessagePipelineState } from '@/core/pipeline/store'; +import { useMessagePipeline } from '@/core/pipeline/useMessagePipeline'; +import type { Player } from '@/core/types/player'; +import { TopicQuickPicker } from '../framework'; +import type { JointStateField, PlotConfig } from './defaults'; +import { JOINT_STATE_FIELDS } from './defaults'; +import { filterPlottableTopics } from './plottableSchemas'; +import { pickDefaultPlotTopic } from './pickDefaultPlotTopic'; +import { secToTime } from './plotChart'; +import { applyJointStateFieldsToConfig, pruneHiddenLegendKeysForDataset } from './plotConfigActions'; +import { + buildTopicByName, + hasConfiguredPlotPaths, + hasEnabledPlotPaths, + isPrimaryJointState, + selectActivePlotTopics, + selectPrimarySeries, +} from './plotConfigSelectors'; +import { hiddenSeriesIndices } from './plotLegendVisibility'; +import { + clearPlotLegendEntries, + setPlotLegendEntries, +} from './plotPanelRuntimeStore'; +import { formatPlotDatasetWarning } from './plotWarnings'; +import { usePlotChart } from './usePlotChart'; +import { usePlotPanelData } from './usePlotPanelData'; +import { usePlotTopicDetection } from './usePlotTopicDetection'; +import { timeToSec } from '@/core/analysis/timeSeries'; + +interface PlotPanelProps { + player: Player; + panelId: string; + config: PlotConfig; + setConfig: (next: PlotConfig | ((prev: PlotConfig) => PlotConfig)) => void; +} + +function JointStateFieldChips({ + fields, + fieldLabels, + onChange, +}: { + fields: JointStateField[]; + fieldLabels: Record; + onChange: (fields: JointStateField[]) => void; +}): React.ReactNode { + return ( +
+ {JOINT_STATE_FIELDS.map((field) => { + const active = fields.includes(field); + return ( + + ); + })} +
+ ); +} + +export const PlotPanel: React.FC = ({ player, panelId, config, setConfig }) => { + const { formatMessage } = useIntl(); + const containerRef = useRef(null); + const autoTopicAppliedRef = useRef(false); + + const { startTime, endTime, randomAccessByTopic, topics } = useMessagePipeline( + useShallow((state: MessagePipelineState) => ({ + startTime: state.playerState.activeData?.startTime, + endTime: state.playerState.activeData?.endTime, + randomAccessByTopic: state.playerState.activeData?.randomAccessByTopic, + topics: state.playerState.activeData?.topics ?? [], + })), + ); + + const plottableTopics = useMemo(() => filterPlottableTopics(topics), [topics]); + const topicByName = useMemo(() => buildTopicByName(topics), [topics]); + const activeTopics = useMemo( + () => selectActivePlotTopics(config, topicByName), + [config, topicByName], + ); + const hasPlotPaths = useMemo(() => hasConfiguredPlotPaths(config), [config]); + const hasEnabledSeries = useMemo(() => hasEnabledPlotPaths(config), [config]); + const primary = selectPrimarySeries(config); + const showJointStateFields = isPrimaryJointState(config, topicByName); + + const xRange = useMemo(() => { + if (!startTime || !endTime || config.xAxisMode !== 'timestamp') return undefined; + return { min: timeToSec(startTime), max: timeToSec(endTime) }; + }, [config.xAxisMode, endTime, startTime]); + + const { detectingTopic, applyTopicDetection } = usePlotTopicDetection({ + player, + config, + setConfig, + topicByName, + startTime, + endTime, + }); + + const { dataset, loading, progress, error } = usePlotPanelData({ + player, + config, + activeTopics, + hasPlotPaths, + startTime, + endTime, + randomAccessByTopic, + }); + + const hiddenSeries = useMemo( + () => hiddenSeriesIndices(dataset.series, config.hiddenLegendKeys), + [config.hiddenLegendKeys, dataset.series], + ); + + const uplotRef = usePlotChart({ + containerRef, + player, + panelId, + config, + dataset, + hiddenSeries, + xRange, + logStart: startTime, + loading, + }); + + useEffect(() => { + const subscriptions = activeTopics.map((topic) => ({ topic, subscriberId: panelId })); + if (subscriptions.length > 0) { + player.registerSubscriptions(panelId, subscriptions); + } + return () => player.unregisterSubscriptions(panelId); + }, [player, panelId, activeTopics]); + + useEffect(() => { + const entries = dataset.series.map((series) => ({ + key: series.key, + label: series.label, + color: series.color, + })); + setPlotLegendEntries(panelId, entries); + + const keys = entries.map((entry) => entry.key); + setConfig((prev) => pruneHiddenLegendKeysForDataset(prev, keys)); + }, [dataset.series, panelId, setConfig]); + + useEffect(() => () => clearPlotLegendEntries(panelId), [panelId]); + + useEffect(() => { + if (autoTopicAppliedRef.current || !startTime || !endTime) return; + if (primary?.topic) { + autoTopicAppliedRef.current = true; + return; + } + const defaultTopic = pickDefaultPlotTopic(plottableTopics); + if (!defaultTopic || !config.series[0]?.id) return; + autoTopicAppliedRef.current = true; + void applyTopicDetection(config.series[0].id, defaultTopic); + }, [applyTopicDetection, config.series, endTime, plottableTopics, primary?.topic, startTime]); + + const updatePrimaryTopic = (topic: string) => { + const primaryId = config.series[0]?.id; + if (primaryId) void applyTopicDetection(primaryId, topic); + }; + + const handleJointStateFieldsChange = (fields: JointStateField[]) => { + setConfig((prev) => applyJointStateFieldsToConfig(prev, topicByName, fields)); + }; + + const jointFieldLabels = useMemo( + (): Record => ({ + position: formatMessage({ id: 'panels.jointStatePlot.toolbar.field.position' }), + velocity: formatMessage({ id: 'panels.jointStatePlot.toolbar.field.velocity' }), + effort: formatMessage({ id: 'panels.jointStatePlot.toolbar.field.effort' }), + }), + [formatMessage], + ); + + const primaryWarning = dataset.warnings[0]; + const warningText = primaryWarning + ? formatPlotDatasetWarning(primaryWarning, formatMessage) + : undefined; + + const hasSeries = activeTopics.length > 0 && hasEnabledSeries; + const status = detectingTopic + ? formatMessage({ id: 'panels.plot.status.detectingPaths' }) + : loading + ? progress + ? formatMessage( + { id: 'panels.plot.status.loadingProgress' }, + { count: progress.messages.toLocaleString() }, + ) + : formatMessage({ id: 'panels.plot.status.loading' }) + : error + ? error + : hasSeries && dataset.sampleRatio < 1 + ? formatMessage( + { id: 'panels.plot.status.sampling' }, + { percent: Math.round(dataset.sampleRatio * 100) }, + ) + : null; + + const handleChartClick = (event: React.MouseEvent) => { + const chart = uplotRef.current; + if (!chart || config.xAxisMode !== 'timestamp') return; + const rect = chart.root.getBoundingClientRect(); + const x = chart.posToVal(event.clientX - rect.left, 'x'); + if (Number.isFinite(x)) { + player.seek(secToTime(x)); + } + }; + + const showLoadingOverlay = loading && dataset.pointCount === 0; + const progressFraction = + progress && progress.total > 0 ? Math.min(1, progress.completed / progress.total) : null; + + return ( +
+
+
+ +
+ {showJointStateFields && ( + + )} + {status && ( + {status} + )} +
+
+ {/* Slim progress bar that runs across the top while the range read streams in. */} + {loading && ( +
+ {progressFraction == null ? ( +
+ ) : ( +
+ )} +
+ )} +
+ {!hasSeries && ( +
+ {formatMessage({ id: 'panels.plot.empty.selectTopic' })} +
+ )} + {/* Centered overlay only on first paint; subsequent batches stream in on top of the chart. */} + {showLoadingOverlay && hasSeries && !error && ( +
+ {progress + ? formatMessage( + { id: 'panels.plot.status.loadingProgress' }, + { count: progress.messages.toLocaleString() }, + ) + : formatMessage({ id: 'panels.plot.status.loading' })} +
+ )} + {hasSeries && !loading && !detectingTopic && !error && dataset.pointCount === 0 && ( +
+ {formatMessage({ id: 'panels.plot.empty.noNumericData' })} +
+ )} + {warningText && ( +
+ {warningText} +
+ )} +
+
+ ); +}; diff --git a/src/features/panels/Plot/PlotPanelSettings.tsx b/src/features/panels/Plot/PlotPanelSettings.tsx new file mode 100644 index 0000000..f5dcefe --- /dev/null +++ b/src/features/panels/Plot/PlotPanelSettings.tsx @@ -0,0 +1,368 @@ +import React, { useMemo } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; +import { useIntl } from 'react-intl'; +import { useShallow } from 'zustand/react/shallow'; +import type { MessagePipelineState } from '@/core/pipeline/store'; +import { useMessagePipeline } from '@/core/pipeline/useMessagePipeline'; +import type { PanelSettingsContext } from '../framework/types'; +import { + SettingsField, + SettingsNumber, + SettingsSection, + SettingsSelect, + SettingsSwitch, + SettingsText, + TopicAutocomplete, +} from '../framework/settings'; +import { + JOINT_STATE_FIELDS, + MAX_PLOT_POINTS, + MIN_PLOT_POINTS, + PLOT_LINE_STYLES, + type JointStateField, + type PlotConfig, + type PlotLineStyle, + type PlotXAxisMode, +} from './defaults'; +import { exportPlotCsvFromConfig } from './exportCsv'; +import { isArrayLikePlotPath } from './messagePath'; +import { filterPlottableTopics, isPlottableSchema } from './plottableSchemas'; +import { + addPlotSeriesToConfig, + applyJointStateFieldsToConfig, + toggleSeriesEnabled, + updateSeriesInConfig, +} from './plotConfigActions'; +import { buildTopicByName } from './plotConfigSelectors'; +import { PlotLegendSettings } from './PlotLegendSettings'; +import { usePlotTopicDetection } from './usePlotTopicDetection'; + +export function PlotPanelSettings({ + config, + setConfig, + topics, + player, + panelId, +}: PanelSettingsContext): React.ReactNode { + const { formatMessage } = useIntl(); + const { startTime, endTime, randomAccessByTopic } = useMessagePipeline( + useShallow((state: MessagePipelineState) => ({ + startTime: state.playerState.activeData?.startTime, + endTime: state.playerState.activeData?.endTime, + randomAccessByTopic: state.playerState.activeData?.randomAccessByTopic, + })), + ); + + const plottableTopics = useMemo(() => filterPlottableTopics(topics), [topics]); + const topicByName = useMemo(() => buildTopicByName(topics), [topics]); + + const { applyTopicDetection } = usePlotTopicDetection({ + player, + config, + setConfig, + topicByName, + startTime, + endTime, + }); + + const xAxisOptions = useMemo(() => { + const primary = config.series[0]; + const pathLooksArray = isArrayLikePlotPath(primary?.path ?? ''); + const hasXPath = (primary?.xAxisPath ?? '').trim().length > 0; + + const arrayHint = formatMessage({ id: 'panels.plot.settings.enum.xAxis.requiresArrayHint' }); + const xPathHint = formatMessage({ id: 'panels.plot.settings.enum.xAxis.requiresXPathHint' }); + + return [ + { + value: 'timestamp' as const, + label: formatMessage({ id: 'panels.plot.settings.enum.xAxis.timestamp' }), + }, + { + value: 'index' as const, + label: + formatMessage({ id: 'panels.plot.settings.enum.xAxis.index' }) + + (pathLooksArray ? '' : ` ${arrayHint}`), + disabled: !pathLooksArray, + }, + { + value: 'custom' as const, + label: + formatMessage({ id: 'panels.plot.settings.enum.xAxis.custom' }) + + (pathLooksArray ? (hasXPath ? '' : ` ${xPathHint}`) : ` ${arrayHint}`), + disabled: !pathLooksArray || !hasXPath, + }, + { + value: 'currentCustom' as const, + label: + formatMessage({ id: 'panels.plot.settings.enum.xAxis.currentCustom' }) + + (pathLooksArray ? (hasXPath ? '' : ` ${xPathHint}`) : ` ${arrayHint}`), + disabled: !pathLooksArray || !hasXPath, + }, + ]; + }, [config.series, formatMessage]); + + const timestampOptions = useMemo( + () => [ + { value: 'headerStamp' as const, label: formatMessage({ id: 'panels.plot.settings.enum.timestamp.headerStamp' }) }, + { value: 'receiveTime' as const, label: formatMessage({ id: 'panels.plot.settings.enum.timestamp.receiveTime' }) }, + { value: 'publishTime' as const, label: formatMessage({ id: 'panels.plot.settings.enum.timestamp.publishTime' }) }, + ], + [formatMessage], + ); + + const lineStyleOptions = useMemo( + () => + PLOT_LINE_STYLES.map((style) => ({ + value: style, + label: formatMessage({ + id: style === 'solid' + ? 'panels.plot.settings.enum.lineStyle.solid' + : 'panels.plot.settings.enum.lineStyle.dashed', + }), + })), + [formatMessage], + ); + + const jointFieldOptions = useMemo( + () => + JOINT_STATE_FIELDS.map((field) => ({ + value: field, + label: formatMessage({ + id: + field === 'position' + ? 'panels.jointStatePlot.toolbar.field.position' + : field === 'velocity' + ? 'panels.jointStatePlot.toolbar.field.velocity' + : 'panels.jointStatePlot.toolbar.field.effort', + }), + })), + [formatMessage], + ); + + return ( +
+ + + + + value={config.xAxisMode} + options={xAxisOptions} + onChange={(xAxisMode) => setConfig({ ...config, xAxisMode })} + /> + + + setConfig({ ...config, maxPoints })} + /> + + + setConfig({ ...config, nonIndexedMaxMessages })} + /> + + +
+ {jointFieldOptions.map((option) => { + const active = config.jointStateFields.includes(option.value); + return ( + + ); + })} +
+
+ + setConfig({ ...config, followingViewWidthSec })} + /> + + + setConfig({ ...config, syncX })} + /> + + + + +
+ + + {config.series.map((series, index) => ( +
+
+ + {formatMessage({ id: 'panels.plot.settings.series.title' }, { index: index + 1 })} + + +
+ + { + void applyTopicDetection(series.id, topic); + }} + placeholder="/topic" + /> + + + setConfig((prev) => updateSeriesInConfig(prev, series.id, { path }))} + placeholder={formatMessage({ id: 'panels.plot.settings.field.yPath.placeholder' })} + /> + + {(config.xAxisMode === 'custom' || config.xAxisMode === 'currentCustom') && ( + + + setConfig((prev) => updateSeriesInConfig(prev, series.id, { xAxisPath })) + } + placeholder={formatMessage({ id: 'panels.plot.settings.field.xPath.placeholder' })} + /> + + )} + + setConfig((prev) => updateSeriesInConfig(prev, series.id, { label }))} + placeholder={formatMessage({ id: 'panels.plot.settings.field.label.placeholder' })} + /> + + + + setConfig((prev) => updateSeriesInConfig(prev, series.id, { timestampMode })) + } + /> + + + + value={series.lineStyle} + options={lineStyleOptions} + onChange={(lineStyle) => + setConfig((prev) => updateSeriesInConfig(prev, series.id, { lineStyle })) + } + /> + + + + setConfig((prev) => updateSeriesInConfig(prev, series.id, { lineSize })) + } + /> + +
+ ))} + +
+
+ ); +} diff --git a/src/features/panels/Plot/adapters/index.ts b/src/features/panels/Plot/adapters/index.ts new file mode 100644 index 0000000..c22d06a --- /dev/null +++ b/src/features/panels/Plot/adapters/index.ts @@ -0,0 +1,182 @@ +import type { JointStateField } from '../defaults'; +import type { AdapterContext, DetectedPlotPath, PlotTypeAdapter } from '../schemaRegistry/types'; +import { extractPlotPathValues } from '../messagePath'; + +const DEFAULT_JOINT_FIELDS: JointStateField[] = ['position']; + +export const jointStateAdapter: PlotTypeAdapter = { + detect(ctx: AdapterContext): DetectedPlotPath[] { + const fields = ctx.jointStateFields?.length ? ctx.jointStateFields : DEFAULT_JOINT_FIELDS; + return fields.map((field) => ({ path: `${field}[:]`, label: field })); + }, + validate(sample: unknown): boolean { + if (!sample || typeof sample !== 'object') return false; + const record = sample as Record; + return ['position', 'velocity', 'effort'].some((field) => { + const arr = record[field]; + return Array.isArray(arr) && arr.length > 0; + }); + }, +}; + +export const LASER_SCAN_ANGLE_X_PATH = '__laser_scan_angle__'; + +export const laserScanAdapter: PlotTypeAdapter = { + detect(ctx: AdapterContext): DetectedPlotPath[] { + const suffix = ctx.schemaName?.toLowerCase() ?? ''; + if (suffix.includes('multiecho')) { + return [{ path: 'ranges[0][:]', label: 'ranges[0]', xAxisPath: LASER_SCAN_ANGLE_X_PATH }]; + } + return [{ path: 'ranges[:]', label: 'ranges', xAxisPath: LASER_SCAN_ANGLE_X_PATH }]; + }, + validate(sample: unknown): boolean { + if (!sample || typeof sample !== 'object') return false; + const ranges = (sample as Record).ranges; + return Array.isArray(ranges) && ranges.length > 0; + }, +}; + +export function imuPaths(): DetectedPlotPath[] { + return [ + { path: 'linear_acceleration.x', label: 'linear_acceleration.x' }, + { path: 'linear_acceleration.y', label: 'linear_acceleration.y' }, + { path: 'linear_acceleration.z', label: 'linear_acceleration.z' }, + { path: 'angular_velocity.x', label: 'angular_velocity.x' }, + { path: 'angular_velocity.y', label: 'angular_velocity.y' }, + { path: 'angular_velocity.z', label: 'angular_velocity.z' }, + ]; +} + +export function magneticFieldPaths(): DetectedPlotPath[] { + return [ + { path: 'magnetic_field.x', label: 'magnetic_field.x' }, + { path: 'magnetic_field.y', label: 'magnetic_field.y' }, + { path: 'magnetic_field.z', label: 'magnetic_field.z' }, + ]; +} + +export const vector3GroupAdapter: PlotTypeAdapter = { + detect(ctx: AdapterContext): DetectedPlotPath[] { + const suffix = ctx.schemaName?.toLowerCase().replace(/\/msg\//, '/') ?? ''; + if (suffix.endsWith('/imu')) return imuPaths(); + if (suffix.endsWith('/magneticfield')) return magneticFieldPaths(); + return [ + { path: 'x', label: 'x' }, + { path: 'y', label: 'y' }, + { path: 'z', label: 'z' }, + ]; + }, +}; + +export const scalarAdapter: PlotTypeAdapter = { + detect(ctx: AdapterContext): DetectedPlotPath[] { + const suffix = ctx.schemaName?.toLowerCase().replace(/\/msg\//, '/') ?? ''; + if (suffix.endsWith('/temperature')) return [{ path: 'temperature', label: 'temperature' }]; + if (suffix.endsWith('/fluidpressure')) return [{ path: 'fluid_pressure', label: 'fluid_pressure' }]; + if (suffix.endsWith('/illuminance')) return [{ path: 'illuminance', label: 'illuminance' }]; + if (suffix.endsWith('/relativehumidity')) return [{ path: 'relative_humidity', label: 'relative_humidity' }]; + if (suffix.endsWith('/range')) return [{ path: 'range', label: 'range' }]; + return [{ path: 'data', label: 'data' }]; + }, +}; + +export const scalarGroupAdapter: PlotTypeAdapter = { + detect(): DetectedPlotPath[] { + return [ + { path: 'latitude', label: 'latitude' }, + { path: 'longitude', label: 'longitude' }, + { path: 'altitude', label: 'altitude' }, + ]; + }, +}; + +export const multiArrayAdapter: PlotTypeAdapter = { + detect(): DetectedPlotPath[] { + return [{ path: 'data[:]', label: 'data' }]; + }, +}; + +export const numericArrayAdapter: PlotTypeAdapter = { + detect(ctx: AdapterContext): DetectedPlotPath[] { + const suffix = ctx.schemaName?.toLowerCase().replace(/\/msg\//, '/') ?? ''; + if (suffix.endsWith('/joy')) { + return [{ path: 'axes[:]', label: 'axes' }]; + } + if (suffix.endsWith('/channelfloat32')) { + return [{ path: 'values[:]', label: 'values' }]; + } + return [{ path: 'data[:]', label: 'data' }]; + }, +}; + +export const batteryStateAdapter: PlotTypeAdapter = { + detect(): DetectedPlotPath[] { + return [ + { path: 'percentage', label: 'percentage' }, + { path: 'voltage', label: 'voltage' }, + ]; + }, +}; + +export const twistAdapter: PlotTypeAdapter = { + detect(ctx: AdapterContext): DetectedPlotPath[] { + const suffix = ctx.schemaName?.toLowerCase().replace(/\/msg\//, '/') ?? ''; + const prefix = suffix.endsWith('/twiststamped') ? 'twist.' : ''; + return [ + { path: `${prefix}linear.x`, label: 'linear.x' }, + { path: `${prefix}linear.y`, label: 'linear.y' }, + { path: `${prefix}angular.z`, label: 'angular.z' }, + ]; + }, +}; + +export const poseAdapter: PlotTypeAdapter = { + detect(ctx: AdapterContext): DetectedPlotPath[] { + const suffix = ctx.schemaName?.toLowerCase().replace(/\/msg\//, '/') ?? ''; + if (suffix.endsWith('/pointstamped')) { + return [ + { path: 'point.x', label: 'point.x' }, + { path: 'point.y', label: 'point.y' }, + { path: 'point.z', label: 'point.z' }, + ]; + } + const prefix = suffix.endsWith('/posestamped') ? 'pose.' : ''; + return [ + { path: `${prefix}position.x`, label: 'position.x' }, + { path: `${prefix}position.y`, label: 'position.y' }, + { path: `${prefix}position.z`, label: 'position.z' }, + ]; + }, +}; + +export const wrenchAdapter: PlotTypeAdapter = { + detect(ctx: AdapterContext): DetectedPlotPath[] { + const suffix = ctx.schemaName?.toLowerCase().replace(/\/msg\//, '/') ?? ''; + const prefix = suffix.endsWith('/wrenchstamped') ? 'wrench.' : ''; + return [ + { path: `${prefix}force.x`, label: 'force.x' }, + { path: `${prefix}force.y`, label: 'force.y' }, + { path: `${prefix}force.z`, label: 'force.z' }, + ]; + }, +}; + +export const odometryAdapter: PlotTypeAdapter = { + detect(): DetectedPlotPath[] { + return [ + { path: 'pose.pose.position.x', label: 'position.x' }, + { path: 'pose.pose.position.y', label: 'position.y' }, + { path: 'twist.twist.linear.x', label: 'linear.x' }, + ]; + }, +}; + +export function validateDetectedPaths(sample: unknown, paths: DetectedPlotPath[]): DetectedPlotPath[] { + if (!sample) return paths; + return paths.filter((entry) => { + if (entry.xAxisPath === LASER_SCAN_ANGLE_X_PATH) { + return laserScanAdapter.validate?.(sample) ?? false; + } + return extractPlotPathValues(sample, entry.path).length > 0; + }); +} diff --git a/src/features/panels/Plot/autoDetect.test.ts b/src/features/panels/Plot/autoDetect.test.ts new file mode 100644 index 0000000..e286f55 --- /dev/null +++ b/src/features/panels/Plot/autoDetect.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { detectPlotPaths, normalizeSchemaName } from './autoDetect'; + +describe('normalizeSchemaName', () => { + it('normalizes ROS2 schema names', () => { + expect(normalizeSchemaName('sensor_msgs/msg/JointState')).toBe('sensor_msgs/jointstate'); + }); +}); + +describe('detectPlotPaths', () => { + it('detects JointState paths from ROS2 schema', () => { + expect(detectPlotPaths({ schemaName: 'sensor_msgs/msg/JointState' })).toEqual([ + { path: 'position[:]', label: 'position' }, + ]); + }); + + it('detects JointState paths from ROS1 schema', () => { + expect(detectPlotPaths({ schemaName: 'sensor_msgs/JointState' })).toEqual([ + { path: 'position[:]', label: 'position' }, + ]); + }); + + it('detects Imu component paths', () => { + const paths = detectPlotPaths({ schemaName: 'sensor_msgs/msg/Imu' }); + expect(paths.map((entry) => entry.path)).toEqual([ + 'linear_acceleration.x', + 'linear_acceleration.y', + 'linear_acceleration.z', + 'angular_velocity.x', + 'angular_velocity.y', + 'angular_velocity.z', + ]); + }); + + it('detects TwistStamped before generic Twist', () => { + const paths = detectPlotPaths({ schemaName: 'geometry_msgs/msg/TwistStamped' }); + expect(paths[0]?.path).toBe('twist.linear.x'); + }); + + it('detects Float64MultiArray', () => { + expect(detectPlotPaths({ schemaName: 'std_msgs/msg/Float64MultiArray' })).toEqual([ + { path: 'data[:]', label: 'data' }, + ]); + }); + + it('returns empty for unknown schemas', () => { + expect(detectPlotPaths({ schemaName: 'custom/Unknown' })).toEqual([]); + }); + + it('returns empty for Image schemas', () => { + expect(detectPlotPaths({ schemaName: 'sensor_msgs/msg/Image' })).toEqual([]); + }); +}); diff --git a/src/features/panels/Plot/autoDetect.ts b/src/features/panels/Plot/autoDetect.ts new file mode 100644 index 0000000..6e03a5e --- /dev/null +++ b/src/features/panels/Plot/autoDetect.ts @@ -0,0 +1,61 @@ +import type { JointStateField } from './defaults'; +import { + batteryStateAdapter, + jointStateAdapter, + laserScanAdapter, + multiArrayAdapter, + numericArrayAdapter, + odometryAdapter, + poseAdapter, + scalarAdapter, + scalarGroupAdapter, + twistAdapter, + validateDetectedPaths, + vector3GroupAdapter, + wrenchAdapter, +} from './adapters'; +import { lookupPlotSchema } from './schemaRegistry/plotSchemaRegistry'; +import type { AdapterContext, DetectedPlotPath, PlotAdapterId, PlotTypeAdapter } from './schemaRegistry/types'; + +export type { DetectedPlotPath } from './schemaRegistry/types'; + +const ADAPTERS: Record = { + jointState: jointStateAdapter, + vector3Group: vector3GroupAdapter, + scalar: scalarAdapter, + scalarGroup: scalarGroupAdapter, + multiArray: multiArrayAdapter, + numericArray: numericArrayAdapter, + laserScan: laserScanAdapter, + batteryState: batteryStateAdapter, + twist: twistAdapter, + pose: poseAdapter, + wrench: wrenchAdapter, + odometry: odometryAdapter, +}; + +export function detectPlotPaths(args: { + schemaName?: string; + sample?: unknown; + jointStateFields?: JointStateField[]; +}): DetectedPlotPath[] { + const { schemaName, sample, jointStateFields } = args; + if (!schemaName) return []; + + const entry = lookupPlotSchema(schemaName); + if (!entry) return []; + + const adapter = ADAPTERS[entry.adapterId]; + const ctx: AdapterContext = { schemaName, sample, jointStateFields }; + const paths = adapter.detect(ctx); + + const validated = validateDetectedPaths(sample, paths); + return validated.length > 0 ? validated : paths; +} + +export function getPreferredXAxisMode(schemaName?: string) { + if (!schemaName) return undefined; + return lookupPlotSchema(schemaName)?.preferredXAxisMode; +} + +export { schemaSuffixFromType as normalizeSchemaName } from './schemaRegistry/plotSchemaRegistry'; diff --git a/src/features/panels/Plot/datasets.test.ts b/src/features/panels/Plot/datasets.test.ts new file mode 100644 index 0000000..8424607 --- /dev/null +++ b/src/features/panels/Plot/datasets.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'vitest'; +import type { MessageEvent } from '@/core/types/ros'; +import { buildPlotDataset } from './datasets'; +import { defaultPlotConfig } from './defaults'; + +function event(topic: string, sec: number, message: unknown, schemaName = 'std_msgs/msg/Float64MultiArray'): MessageEvent { + return { + topic, + receiveTime: { sec, nsec: 0 }, + publishTime: { sec, nsec: 0 }, + message, + schemaName, + }; +} + +describe('buildPlotDataset', () => { + it('builds timestamp datasets for Float64MultiArray slices before playback', () => { + const config = { + ...defaultPlotConfig(), + series: [{ + ...defaultPlotConfig().series[0], + id: 's1', + topic: '/array', + path: 'data[:]', + timestampMode: 'receiveTime' as const, + }], + }; + const dataset = buildPlotDataset( + [ + event('/array', 1, { data: [1, 2] }), + event('/array', 2, { data: [3, 4] }), + ], + config, + ); + expect(dataset.series.map((series) => series.label)).toEqual([ + 'data[0]', + 'data[1]', + ]); + expect(dataset.data[0]).toEqual([1, 2]); + expect(dataset.data[1]).toEqual([1, 3]); + expect(dataset.data[2]).toEqual([2, 4]); + }); + + it('builds JointState datasets using joint names', () => { + const config = { + ...defaultPlotConfig(), + series: [{ + ...defaultPlotConfig().series[0], + id: 'joints', + topic: '/joint_states', + path: 'position[:]', + timestampMode: 'receiveTime' as const, + }], + }; + const dataset = buildPlotDataset( + [ + event('/joint_states', 1, { name: ['a', 'b'], position: [0.1, 0.2] }, 'sensor_msgs/msg/JointState'), + event('/joint_states', 2, { name: ['a', 'b'], position: [0.3, 0.4] }, 'sensor_msgs/msg/JointState'), + ], + config, + ); + expect(dataset.series.map((series) => series.label)).toEqual([ + 'position[0] (a)', + 'position[1] (b)', + ]); + expect(dataset.data[1]).toEqual([0.1, 0.3]); + expect(dataset.data[2]).toEqual([0.2, 0.4]); + }); + + it('builds bounded JointState slices with Foxglove inclusive bounds', () => { + const config = { + ...defaultPlotConfig(), + series: [{ + ...defaultPlotConfig().series[0], + id: 'joints', + topic: '/joint_states', + path: 'position[1:2]', + timestampMode: 'receiveTime' as const, + }], + }; + const dataset = buildPlotDataset( + [ + event('/joint_states', 1, { name: ['a', 'b', 'c'], position: [0.1, 0.2, 0.3] }, 'sensor_msgs/msg/JointState'), + ], + config, + ); + expect(dataset.series.map((series) => series.label)).toEqual([ + 'position[1] (b)', + 'position[2] (c)', + ]); + expect(dataset.data.length).toBe(3); + }); + + it('uses only the latest message in index mode', () => { + const config = { + ...defaultPlotConfig(), + xAxisMode: 'index' as const, + series: [{ + ...defaultPlotConfig().series[0], + id: 's1', + topic: '/array', + path: 'data[:]', + }], + }; + const dataset = buildPlotDataset( + [ + event('/array', 1, { data: [1, 2] }), + event('/array', 2, { data: [3, 4] }), + ], + config, + ); + expect(dataset.data[0]).toEqual([0, 1]); + expect(dataset.data[1]).toEqual([3, null]); + expect(dataset.data[2]).toEqual([null, 4]); + }); + + it('pairs custom x and y arrays', () => { + const config = { + ...defaultPlotConfig(), + xAxisMode: 'custom' as const, + series: [{ + ...defaultPlotConfig().series[0], + id: 's1', + topic: '/scan', + xAxisPath: 'x[:]', + path: 'y[:]', + }], + }; + const dataset = buildPlotDataset([event('/scan', 1, { x: [10, 20], y: [5, 6] })], config); + expect(dataset.data[0]).toEqual([10, 20]); + expect(dataset.data[1]).toEqual([5, 6]); + }); + + it('derives values without appending seek backfill into existing history', () => { + const config = { + ...defaultPlotConfig(), + series: [{ + ...defaultPlotConfig().series[0], + id: 's1', + topic: '/value', + path: 'data@derivative', + timestampMode: 'receiveTime' as const, + }], + }; + const dataset = buildPlotDataset( + [ + event('/value', 1, { data: 1 }), + event('/value', 3, { data: 5 }), + ], + config, + ); + expect(dataset.data[0]).toEqual([3]); + expect(dataset.data[1]).toEqual([2]); + }); + + it('assigns distinct palette colors when one series expands to multiple buckets', () => { + const config = { + ...defaultPlotConfig(), + series: [{ + ...defaultPlotConfig().series[0], + id: 'joints', + topic: '/joint_states', + path: 'position[:]', + color: '#000000', + timestampMode: 'receiveTime' as const, + }], + }; + const dataset = buildPlotDataset( + [event('/joint_states', 1, { name: ['a', 'b'], position: [0.1, 0.2] }, 'sensor_msgs/msg/JointState')], + config, + ); + expect(dataset.series[0]?.color).not.toBe(dataset.series[1]?.color); + expect(dataset.series[0]?.color).not.toBe('#000000'); + }); + + it('keeps multi-series JointState lines continuous under downsampling', () => { + const config = { + ...defaultPlotConfig(), + maxPoints: 50, + downsampleMode: 'minMaxLast' as const, + series: [{ + ...defaultPlotConfig().series[0], + id: 'joints', + topic: '/joint_states', + path: 'position[:]', + timestampMode: 'receiveTime' as const, + }], + }; + const events = Array.from({ length: 200 }, (_, index) => + event('/joint_states', index, { name: ['a', 'b'], position: [index, index * 2] }, 'sensor_msgs/msg/JointState'), + ); + const dataset = buildPlotDataset(events, config); + for (let seriesIndex = 1; seriesIndex < dataset.data.length; seriesIndex++) { + const values = dataset.data[seriesIndex] as Array; + const nonNullIndices = values.flatMap((value, index) => (value != null ? [index] : [])); + for (let index = 1; index < nonNullIndices.length; index++) { + const curr = nonNullIndices[index]; + const prev = nonNullIndices[index - 1]; + if (curr === undefined || prev === undefined) { + throw new Error('expected consecutive non-null indices'); + } + expect(curr - prev).toBe(1); + } + } + }); + + it('preserves each series timeline when multiple topics are overlaid', () => { + const config = { + ...defaultPlotConfig(), + maxPoints: 50, + downsampleMode: 'minMaxLast' as const, + series: [ + { + ...defaultPlotConfig().series[0], + id: 's1', + topic: '/cmd', + path: 'data', + timestampMode: 'receiveTime' as const, + }, + { + ...defaultPlotConfig().series[0], + id: 's2', + topic: '/state', + path: 'data', + timestampMode: 'receiveTime' as const, + }, + ], + }; + const events = [ + ...Array.from({ length: 100 }, (_, index) => event('/cmd', index, { data: index })), + ...Array.from({ length: 100 }, (_, index) => event('/state', index + 0.5, { data: index * 10 })), + ]; + const dataset = buildPlotDataset(events, config); + const xValues = dataset.data[0] as number[]; + for (let seriesIndex = 1; seriesIndex < dataset.data.length; seriesIndex++) { + const yValues = dataset.data[seriesIndex] as Array; + const native = xValues.flatMap((x, index) => { + const y = yValues[index]; + return y != null ? [{ x, y }] : []; + }); + for (let index = 1; index < native.length; index++) { + const curr = native[index]; + const prev = native[index - 1]; + if (!curr || !prev) { + throw new Error('expected consecutive native points'); + } + expect(curr.x).toBeGreaterThan(prev.x); + } + expect(native.length).toBeGreaterThan(1); + } + }); + + it('falls back to receiveTime when header stamp is outside the log window', () => { + const logStart = { sec: 1_735_689_600, nsec: 0 }; + const logEnd = { sec: 1_735_689_654, nsec: 0 }; + const config = { + ...defaultPlotConfig(), + series: [{ + ...defaultPlotConfig().series[0], + id: 'joints', + topic: '/joint_states', + path: 'position[0]', + timestampMode: 'headerStamp' as const, + }], + }; + const dataset = buildPlotDataset( + [{ + topic: '/joint_states', + receiveTime: { sec: 1_735_689_610, nsec: 0 }, + publishTime: { sec: 1_735_689_610, nsec: 0 }, + message: { + header: { stamp: { sec: 0, nsec: 0 } }, + name: ['a'], + position: [1.5], + }, + schemaName: 'sensor_msgs/msg/JointState', + }], + config, + { logStart, logEnd }, + ); + expect(dataset.data[0]).toEqual([1_735_689_610]); + expect(dataset.data[1]).toEqual([1.5]); + expect(dataset.pointCount).toBe(1); + }); + + it('overlays two different topics on the same chart', () => { + const config = { + ...defaultPlotConfig(), + series: [ + { + ...defaultPlotConfig().series[0], + id: 's1', + topic: '/joint_cmd', + path: 'position[0]', + color: '#3b82f6', + timestampMode: 'receiveTime' as const, + }, + { + ...defaultPlotConfig().series[0], + id: 's2', + topic: '/joint_states', + path: 'position[0]', + color: '#ef4444', + timestampMode: 'receiveTime' as const, + }, + ], + }; + const joint = 'sensor_msgs/msg/JointState'; + const dataset = buildPlotDataset( + [ + event('/joint_cmd', 1, { name: ['a'], position: [1] }, joint), + event('/joint_cmd', 2, { name: ['a'], position: [2] }, joint), + event('/joint_states', 1, { name: ['a'], position: [10] }, joint), + event('/joint_states', 2, { name: ['a'], position: [20] }, joint), + ], + config, + ); + expect(dataset.series).toHaveLength(2); + expect(dataset.series.map((s) => s.label)).toEqual([ + '/joint_cmd · position[0]', + '/joint_states · position[0]', + ]); + expect(dataset.data[0]).toEqual([1, 2]); + expect(dataset.data[1]).toEqual([1, 2]); + expect(dataset.data[2]).toEqual([10, 20]); + expect(dataset.pointCount).toBe(4); + }); + + it('forces downsampling for non-indexed sources via options', () => { + const config = { + ...defaultPlotConfig(), + downsampleMode: 'none' as const, + maxPoints: 100, + series: [{ + ...defaultPlotConfig().series[0], + id: 's1', + topic: '/value', + path: 'data', + timestampMode: 'receiveTime' as const, + }], + }; + const events = Array.from({ length: 500 }, (_, index) => + event('/value', index, { data: index }), + ); + const dataset = buildPlotDataset(events, config, { + forceDownsample: true, + extraWarnings: [{ kind: 'downsampleLimited' }], + }); + expect(dataset.warnings.some((w) => w.kind === 'downsampleLimited')).toBe(true); + expect((dataset.data[0] as number[]).length).toBeLessThanOrEqual(100); + }); +}); diff --git a/src/features/panels/Plot/datasets.ts b/src/features/panels/Plot/datasets.ts new file mode 100644 index 0000000..d480283 --- /dev/null +++ b/src/features/panels/Plot/datasets.ts @@ -0,0 +1,55 @@ +import type { MessageEvent } from '@/core/types/ros'; +import { paletteColor, type PlotConfig } from './defaults'; +import { plotWarningKey, type PlotDatasetWarning } from './plotWarnings'; +import type { BuildPlotDatasetOptions, PlotDataset } from './types'; +import { alignBuckets } from './plotAlign'; +import { + assignBucketColors, + collectCustomPoints, + collectIndexPoints, + collectTimestampPoints, +} from './plotPointCollector'; + +export type { PlotRuntimeSeries, PlotDataset, BuildPlotDatasetOptions } from './types'; +export { quantizePlotX } from './plotPointCollector'; +export { indexEventsByTopic } from './plotEventIndex'; + +export function buildPlotDataset( + events: MessageEvent[], + config: PlotConfig, + options: BuildPlotDatasetOptions = {}, +): PlotDataset { + const warnings: PlotDatasetWarning[] = [...(options.extraWarnings ?? [])]; + const buckets = + config.xAxisMode === 'timestamp' + ? collectTimestampPoints(events, config, warnings, options.logStart, options.logEnd) + : config.xAxisMode === 'index' + ? collectIndexPoints(events, config) + : collectCustomPoints(events, config, config.xAxisMode === 'currentCustom', warnings); + + assignBucketColors(buckets); + + const seriesBuckets = [...buckets.values()]; + const shouldDownsample = options.forceDownsample === true || config.downsampleMode === 'minMaxLast'; + const { data, sampleRatio } = alignBuckets(seriesBuckets, config.maxPoints, shouldDownsample); + + let pointCount = 0; + for (let i = 1; i < data.length; i++) { + const arr = data[i] as Array; + for (let j = 0; j < arr.length; j++) { + if (arr[j] != null) pointCount++; + } + } + + return { + xLabel: config.xAxisMode === 'timestamp' ? 'time' : config.xAxisMode === 'index' ? 'index' : 'x', + series: seriesBuckets.map((bucket) => bucket.series), + data, + pointCount, + sampleRatio, + warnings: Array.from(new Map(warnings.map((w) => [plotWarningKey(w), w])).values()), + }; +} + +// Re-export palette for tests +export { paletteColor }; diff --git a/src/features/panels/Plot/defaults.ts b/src/features/panels/Plot/defaults.ts new file mode 100644 index 0000000..546dee6 --- /dev/null +++ b/src/features/panels/Plot/defaults.ts @@ -0,0 +1,99 @@ +import type { DownsampleMode, TimestampMode } from '@/core/analysis/timeSeries'; + +export const PLOT_X_AXIS_MODES = ['timestamp', 'index', 'custom', 'currentCustom'] as const; +export type PlotXAxisMode = (typeof PLOT_X_AXIS_MODES)[number]; + +export const JOINT_STATE_FIELDS = ['position', 'velocity', 'effort'] as const; +export type JointStateField = (typeof JOINT_STATE_FIELDS)[number]; + +export const PLOT_LINE_STYLES = ['solid', 'dashed'] as const; +export type PlotLineStyle = (typeof PLOT_LINE_STYLES)[number]; + +export interface PlotSeriesConfig { + id: string; + topic: string; + path: string; + xAxisPath?: string; + label: string; + color: string; + enabled: boolean; + timestampMode: TimestampMode; + lineStyle: PlotLineStyle; + lineSize: number; +} + +export interface PlotConfig { + series: PlotSeriesConfig[]; + xAxisMode: PlotXAxisMode; + maxPoints: number; + followingViewWidthSec: number; + syncX: boolean; + downsampleMode: DownsampleMode; + /** Max messages to read from non-indexed sources (e.g. streaming bag). */ + nonIndexedMaxMessages: number; + /** Enabled JointState array fields when plotting JointState topics. */ + jointStateFields: JointStateField[]; + /** Runtime legend keys hidden on the chart (persisted per panel). */ + hiddenLegendKeys: string[]; +} + +export const MIN_PLOT_POINTS = 200; +export const MAX_PLOT_POINTS = 200_000; +export const DEFAULT_NON_INDEXED_MAX_MESSAGES = 20_000; + +/** Tailwind 500/600 theme palette for multi-series plots. */ +export const PLOT_PALETTE = [ + '#3b82f6', // blue-500 + '#ef4444', // red-500 + '#22c55e', // green-500 + '#f59e0b', // amber-500 + '#a855f7', // purple-500 + '#06b6d4', // cyan-500 + '#f97316', // orange-500 + '#84cc16', // lime-500 + '#ec4899', // pink-500 + '#14b8a6', // teal-500 + '#6366f1', // indigo-500 + '#eab308', // yellow-500 + '#0ea5e9', // sky-500 + '#d946ef', // fuchsia-500 + '#10b981', // emerald-500 + '#64748b', // slate-500 +] as const; + +/** @deprecated Use PLOT_PALETTE / paletteColor instead. */ +export const DEFAULT_PLOT_COLORS = PLOT_PALETTE; + +export function paletteColor(index: number): string { + return PLOT_PALETTE[index % PLOT_PALETTE.length] ?? PLOT_PALETTE[0]; +} + +export function createPlotSeries(overrides: Partial = {}): PlotSeriesConfig { + const id = overrides.id ?? `series-${Math.random().toString(36).slice(2, 10)}`; + const colorIndex = overrides.color ? -1 : 0; + return { + id, + topic: '', + path: '', + xAxisPath: '', + label: '', + color: overrides.color ?? paletteColor(colorIndex), + enabled: true, + timestampMode: 'headerStamp', + lineStyle: 'solid', + lineSize: 1.5, + ...overrides, + }; +} + +export const defaultPlotConfig = (): PlotConfig => ({ + series: [createPlotSeries()], + xAxisMode: 'timestamp', + maxPoints: 20_000, + followingViewWidthSec: 0, + syncX: false, + downsampleMode: 'minMaxLast', + nonIndexedMaxMessages: DEFAULT_NON_INDEXED_MAX_MESSAGES, + jointStateFields: ['position'], + hiddenLegendKeys: [], +}); diff --git a/src/features/panels/Plot/definition.tsx b/src/features/panels/Plot/definition.tsx new file mode 100644 index 0000000..98ce406 --- /dev/null +++ b/src/features/panels/Plot/definition.tsx @@ -0,0 +1,123 @@ +import { lazy } from 'react'; +import type { PanelDefinition } from '../framework/types'; +import { PanelSuspense } from '../framework/panelSuspense'; +import { + collectExtras, + FOXGLOVE_PANEL_TITLE_KEY, + mergeWithExtras, + type FoxgloveAdapterDecoded, + type FoxgloveAdapterState, + type FoxgloveConfig, + type PanelFoxgloveAdapter, +} from '../framework/foxgloveAdapter'; +import { defaultPlotConfig, type PlotConfig, type PlotSeriesConfig } from './defaults'; +import { parsePlotConfig } from './schema'; +import { PlotPanelSettings } from './PlotPanelSettings'; +import { listPlotSchemaEntries } from './schemaRegistry/plotSchemaRegistry'; + +const PlotPanel = lazy(async () => { + const m = await import('./PlotPanel'); + return { default: m.PlotPanel }; +}); + +function splitFoxglovePath(value: string): { topic: string; path: string } { + if (!value.startsWith('/')) return { topic: '', path: value }; + const dot = value.indexOf('.'); + if (dot < 0) return { topic: value, path: 'data' }; + return { topic: value.slice(0, dot), path: value.slice(dot + 1) }; +} + +function parseFoxgloveSeries(config: FoxgloveConfig): Partial[] { + if (!Array.isArray(config.paths)) return []; + return config.paths.flatMap((entry, index) => { + if (!entry || typeof entry !== 'object') return []; + const record = entry as Record; + const value = typeof record.value === 'string' ? record.value : ''; + if (!value) return []; + const split = splitFoxglovePath(value); + return [{ + id: typeof record.id === 'string' ? record.id : `series-${index + 1}`, + topic: split.topic, + path: split.path, + label: typeof record.label === 'string' ? record.label : '', + color: typeof record.color === 'string' ? record.color : undefined, + enabled: typeof record.enabled === 'boolean' ? record.enabled : true, + timestampMode: record.timestampMethod === 'receiveTime' ? 'receiveTime' : 'headerStamp', + lineStyle: record.lineStyle === 'dashed' ? 'dashed' : 'solid', + lineSize: typeof record.lineSize === 'number' ? record.lineSize : 1.5, + }]; + }); +} + +const KNOWN_KEYS = [ + 'series', + 'paths', + 'xAxisMode', + 'xAxisVal', + 'maxPoints', + 'followingViewWidthSec', + 'syncX', + 'downsampleMode', + 'jointStateFields', + 'hiddenLegendKeys', + 'nonIndexedMaxMessages', +] as const; + +function fromConfig(config: FoxgloveConfig): FoxgloveAdapterDecoded { + const series = parseFoxgloveSeries(config); + const xAxisMode = config.xAxisMode ?? config.xAxisVal; + const title = typeof config[FOXGLOVE_PANEL_TITLE_KEY] === 'string' + ? config[FOXGLOVE_PANEL_TITLE_KEY] + : undefined; + return { + config: parsePlotConfig({ ...config, ...(series.length > 0 ? { series } : {}), xAxisMode }), + extras: collectExtras(config, KNOWN_KEYS), + title, + }; +} + +function toConfig(state: FoxgloveAdapterState): FoxgloveConfig { + const known: FoxgloveConfig = { + ...state.config, + paths: state.config.series.map((series) => ({ + value: series.topic ? `${series.topic}.${series.path}` : series.path, + enabled: series.enabled, + color: series.color, + label: series.label, + timestampMethod: series.timestampMode, + lineStyle: series.lineStyle, + lineSize: series.lineSize, + })), + }; + if (state.title && state.title.length > 0) { + known[FOXGLOVE_PANEL_TITLE_KEY] = state.title; + } + return mergeWithExtras(state.extras, known); +} + +export const plotPanelDefinition: PanelDefinition = { + type: 'Plot', + defaultTitle: 'Plot', + createDefaultConfig: defaultPlotConfig, + configSchema: { version: 1, parse: parsePlotConfig }, + render: ({ player, panelId, config, setConfig }) => ( + + + + ), + schemaSupport: { + supportedSchemas: listPlotSchemaEntries().map((entry) => { + const [pkg, type] = entry.schemaSuffix.split('/'); + return `${pkg}/msg/${type.charAt(0).toUpperCase()}${type.slice(1)}`; + }), + }, + renderSettings: (ctx) => , +}; + +export const plotFoxgloveAdapter: PanelFoxgloveAdapter = { + internalType: 'Plot', + foxgloveTypes: ['Plot'], + defaultFoxgloveType: 'Plot', + fromConfig, + toConfig, +}; diff --git a/src/features/panels/Plot/exportCsv.ts b/src/features/panels/Plot/exportCsv.ts new file mode 100644 index 0000000..3032da6 --- /dev/null +++ b/src/features/panels/Plot/exportCsv.ts @@ -0,0 +1,68 @@ +import type { Player } from '@/core/types/player'; +import type { Time } from '@/core/types/ros'; +import { buildPlotDataset, type PlotDataset } from './datasets'; +import type { PlotConfig } from './defaults'; +import { readPlotRange } from './rangeReader'; + +function csvEscape(value: unknown): string { + const text = + value == null + ? '' + : typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : JSON.stringify(value); + return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +export function downloadPlotCsv(dataset: PlotDataset): void { + const xValues = dataset.data[0] as number[]; + const rows: string[] = [ + ['x', ...dataset.series.map((series) => series.label)].map(csvEscape).join(','), + ]; + for (let i = 0; i < xValues.length; i++) { + rows.push( + [ + xValues[i], + ...dataset.series.map((_, seriesIndex) => { + const values = dataset.data[seriesIndex + 1] as Array; + return values[i] ?? ''; + }), + ].map(csvEscape).join(','), + ); + } + const blob = new Blob([rows.join('\n')], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'plot.csv'; + anchor.click(); + URL.revokeObjectURL(url); +} + +export async function exportPlotCsvFromConfig(args: { + player: Player; + config: PlotConfig; + startTime: Time; + endTime: Time; + forceDownsample?: boolean; +}): Promise { + const { player, config, startTime, endTime, forceDownsample } = args; + if (!player.getMessagesInTimeRange) return; + + const topics = Array.from( + new Set(config.series.filter((s) => s.enabled && s.topic).map((s) => s.topic)), + ); + const messages = await readPlotRange({ + player, + start: startTime, + end: endTime, + topics, + maxMessages: forceDownsample ? config.nonIndexedMaxMessages : undefined, + }); + const dataset = buildPlotDataset(messages, config, { + forceDownsample: forceDownsample === true, + logStart: startTime, + logEnd: endTime, + }); + downloadPlotCsv(dataset); +} diff --git a/src/features/panels/Plot/fixtures/gripperPickAndPlace.fixture.ts b/src/features/panels/Plot/fixtures/gripperPickAndPlace.fixture.ts new file mode 100644 index 0000000..9c558a4 --- /dev/null +++ b/src/features/panels/Plot/fixtures/gripperPickAndPlace.fixture.ts @@ -0,0 +1,10 @@ +/** JointState sample derived from gripper pick-and-place demo recordings. */ +export const GRIPPER_JOINT_STATE_SAMPLE = { + name: ['head_joint1', 'head_joint2', 'drive_joint'], + position: [0.12, -0.34, 0.85], + velocity: [0.01, -0.02, 0.0], + effort: [1.2, 0.8, 2.1], +}; + +export const GRIPPER_JOINT_STATE_SCHEMA = 'sensor_msgs/msg/JointState'; +export const GRIPPER_JOINT_STATE_TOPIC = '/joint_states'; diff --git a/src/features/panels/Plot/index.ts b/src/features/panels/Plot/index.ts new file mode 100644 index 0000000..cd2f3c5 --- /dev/null +++ b/src/features/panels/Plot/index.ts @@ -0,0 +1,3 @@ +export { plotPanelDefinition, plotFoxgloveAdapter } from './definition'; +export { defaultPlotConfig, type PlotConfig, type PlotSeriesConfig } from './defaults'; +export { parsePlotConfig } from './schema'; diff --git a/src/features/panels/Plot/jointStatePaths.ts b/src/features/panels/Plot/jointStatePaths.ts new file mode 100644 index 0000000..818d20e --- /dev/null +++ b/src/features/panels/Plot/jointStatePaths.ts @@ -0,0 +1,22 @@ +import type { JointStateField } from './defaults'; + +const JOINT_STATE_SLICE_PATHS = new Set(['position[:]', 'velocity[:]', 'effort[:]']); + +/** Comma-separated Y paths for one config series (e.g. `position[:],velocity[:]`). */ +export function combinePlotPaths(paths: readonly string[]): string { + return paths.filter(Boolean).join(','); +} + +export function buildJointStateCombinedPath(fields: readonly JointStateField[]): string { + return combinePlotPaths(fields.map((field) => `${field}[:]`)); +} + +/** Remove legacy auto-split JointState slots (one field path per extra series). */ +export function stripAutoJointStateSeriesSlots( + series: readonly T[], + topic: string, +): T[] { + return series.filter( + (entry) => entry.topic !== topic || !JOINT_STATE_SLICE_PATHS.has(entry.path), + ); +} diff --git a/src/features/panels/Plot/messagePath.test.ts b/src/features/panels/Plot/messagePath.test.ts new file mode 100644 index 0000000..5402822 --- /dev/null +++ b/src/features/panels/Plot/messagePath.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { extractPlotPathValues, isArrayLikePlotPath } from './messagePath'; + +describe('extractPlotPathValues', () => { + it('extracts scalar numeric fields', () => { + expect(extractPlotPathValues({ data: 1.5 }, 'data')).toEqual([ + { key: 'data', label: 'data', value: 1.5 }, + ]); + }); + + it('extracts Float64MultiArray data slices', () => { + expect(extractPlotPathValues({ data: [1, 2, 3] }, 'data[:]')).toEqual([ + { key: 'data[0]', label: 'data[0]', value: 1 }, + { key: 'data[1]', label: 'data[1]', value: 2 }, + { key: 'data[2]', label: 'data[2]', value: 3 }, + ]); + }); + + it('supports typed arrays', () => { + expect(extractPlotPathValues({ data: new Float64Array([4, 5]) }, 'data[:]')).toEqual([ + { key: 'data[0]', label: 'data[0]', value: 4 }, + { key: 'data[1]', label: 'data[1]', value: 5 }, + ]); + }); + + it('maps JointState arrays by name', () => { + const message = { name: ['shoulder', 'elbow'], position: [0.1, 0.2] }; + expect(extractPlotPathValues(message, 'position[:]')).toEqual([ + { key: 'position[shoulder]', label: 'position[0] (shoulder)', value: 0.1 }, + { key: 'position[elbow]', label: 'position[1] (elbow)', value: 0.2 }, + ]); + expect(extractPlotPathValues(message, 'position[elbow]')).toEqual([ + { key: 'position[elbow]', label: 'position[1] (elbow)', value: 0.2 }, + ]); + }); + + it('applies math modifiers', () => { + expect(extractPlotPathValues({ data: -2 }, 'data@abs')).toEqual([ + { key: 'data', label: 'data', value: 2 }, + ]); + }); + + it('extracts multiple comma-separated paths in one series', () => { + const message = { + name: ['j0', 'j1', 'j2'], + position: [0.1, 0.2, 0.3], + effort: [10, 20, 30], + }; + expect(extractPlotPathValues(message, 'position[1],position[2],effort[1],effort[2]')).toEqual([ + { key: 'position[1]', label: 'position[1]', value: 0.2 }, + { key: 'position[2]', label: 'position[2]', value: 0.3 }, + { key: 'effort[1]', label: 'effort[1]', value: 20 }, + { key: 'effort[2]', label: 'effort[2]', value: 30 }, + ]); + }); + + it('extracts multiple slice paths separated by spaces', () => { + const message = { position: [0.1, 0.2], velocity: [3, 4] }; + expect(extractPlotPathValues(message, 'position[:] velocity[:]')).toEqual([ + { key: 'position[0]', label: 'position[0]', value: 0.1 }, + { key: 'position[1]', label: 'position[1]', value: 0.2 }, + { key: 'velocity[0]', label: 'velocity[0]', value: 3 }, + { key: 'velocity[1]', label: 'velocity[1]', value: 4 }, + ]); + }); + + it('uses Foxglove inclusive bounds for bounded slices', () => { + expect(extractPlotPathValues({ position: [0.1, 0.2, 0.3] }, 'position[1:2]')).toEqual([ + { key: 'position[1]', label: 'position[1]', value: 0.2 }, + { key: 'position[2]', label: 'position[2]', value: 0.3 }, + ]); + expect(extractPlotPathValues({ data: [1, 2, 3, 4, 5] }, 'data[1:3]')).toEqual([ + { key: 'data[1]', label: 'data[1]', value: 2 }, + { key: 'data[2]', label: 'data[2]', value: 3 }, + { key: 'data[3]', label: 'data[3]', value: 4 }, + ]); + expect(extractPlotPathValues({ numbers: [3, 5, 7, 9, 10] }, 'numbers[-2:-1]')).toEqual([ + { key: 'numbers[3]', label: 'numbers[3]', value: 9 }, + { key: 'numbers[4]', label: 'numbers[4]', value: 10 }, + ]); + }); + + it('maps inclusive JointState slices by name', () => { + const message = { name: ['shoulder', 'elbow', 'wrist'], position: [0.1, 0.2, 0.3] }; + expect(extractPlotPathValues(message, 'position[1:2]')).toEqual([ + { key: 'position[elbow]', label: 'position[1] (elbow)', value: 0.2 }, + { key: 'position[wrist]', label: 'position[2] (wrist)', value: 0.3 }, + ]); + }); + + it('supports hyphen slice syntax as an alias for colon slices', () => { + const message = { position: [0.1, 0.2, 0.3] }; + const colon = extractPlotPathValues(message, 'position[1:2]'); + expect(extractPlotPathValues(message, 'position[1-2]')).toEqual(colon); + expect(extractPlotPathValues({ numbers: [3, 5, 7, 9, 10] }, 'numbers[-2--1]')).toEqual( + extractPlotPathValues({ numbers: [3, 5, 7, 9, 10] }, 'numbers[-2:-1]'), + ); + expect(extractPlotPathValues({ data: [1, 2, 3, 4, 5] }, 'data[2-]')).toEqual( + extractPlotPathValues({ data: [1, 2, 3, 4, 5] }, 'data[2:]'), + ); + }); +}); + +describe('isArrayLikePlotPath', () => { + it('detects [:] slice as array-like', () => { + expect(isArrayLikePlotPath('position[:]')).toBe(true); + expect(isArrayLikePlotPath('data[:]')).toBe(true); + }); + + it('detects bounded slice as array-like', () => { + expect(isArrayLikePlotPath('data[1:5]')).toBe(true); + expect(isArrayLikePlotPath('data[:5]')).toBe(true); + expect(isArrayLikePlotPath('data[2:]')).toBe(true); + expect(isArrayLikePlotPath('position[1-2]')).toBe(true); + expect(isArrayLikePlotPath('data[2-]')).toBe(true); + }); + + it('treats scalar paths as not array-like', () => { + expect(isArrayLikePlotPath('data')).toBe(false); + expect(isArrayLikePlotPath('header.stamp.sec')).toBe(false); + expect(isArrayLikePlotPath('')).toBe(false); + }); + + it('treats fixed-index paths as not array-like', () => { + expect(isArrayLikePlotPath('position[0]')).toBe(false); + expect(isArrayLikePlotPath('position[shoulder]')).toBe(false); + }); + + it('returns true if any subpath in a list is array-like', () => { + expect(isArrayLikePlotPath('position[0],velocity[:]')).toBe(true); + expect(isArrayLikePlotPath('position[0],velocity[1]')).toBe(false); + }); + + it('ignores @modifiers', () => { + expect(isArrayLikePlotPath('data[:]@derivative')).toBe(true); + expect(isArrayLikePlotPath('data@abs')).toBe(false); + }); +}); diff --git a/src/features/panels/Plot/messagePath.ts b/src/features/panels/Plot/messagePath.ts new file mode 100644 index 0000000..ef1a336 --- /dev/null +++ b/src/features/panels/Plot/messagePath.ts @@ -0,0 +1,324 @@ +import type { Time } from '@/core/types/ros'; + +export interface ExtractedPlotValue { + key: string; + label: string; + value: number; +} + +export interface ParsedPlotPath { + sourcePath: string; + modifiers: string[]; +} + +type Selector = + | { kind: 'none' } + | { kind: 'index'; index: number } + | { kind: 'slice'; start?: number; end?: number } + | { kind: 'name'; name: string }; + +interface Segment { + field: string; + selector: Selector; +} + +const SEGMENT_RE = /^([A-Za-z_$][\w$]*)(?:\[([^\]]*)\])?$/; + +const mathFunctions: Record number> = { + abs: Math.abs, + acos: Math.acos, + asin: Math.asin, + atan: Math.atan, + ceil: Math.ceil, + cos: Math.cos, + deg2rad: (value) => (value * Math.PI) / 180, + exp: Math.exp, + floor: Math.floor, + log: Math.log, + log10: Math.log10, + rad2deg: (value) => (value * 180) / Math.PI, + round: Math.round, + sin: Math.sin, + sqrt: Math.sqrt, + tan: Math.tan, +}; + +/** Split comma- or whitespace-separated Y paths (each segment may include `@` modifiers). */ +export function splitPlotPathList(path: string): string[] { + const trimmed = path.trim(); + if (!trimmed) return []; + if (!/[,\s]/.test(trimmed)) return [trimmed]; + return trimmed + .split(',') + .flatMap((segment) => segment.trim().split(/\s+/)) + .map((part) => part.trim()) + .filter(Boolean); +} + +export function parsePlotPath(path: string): ParsedPlotPath { + const trimmed = path.trim(); + if (!trimmed) return { sourcePath: '', modifiers: [] }; + const parts = trimmed.split('@').map((part) => part.trim()).filter(Boolean); + return { + sourcePath: parts[0] ?? '', + modifiers: parts.slice(1), + }; +} + +function isArrayLike(value: unknown): value is ArrayLike { + if (Array.isArray(value)) return true; + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) return true; + return false; +} + +function readNames(message: unknown): string[] { + if (!message || typeof message !== 'object') return []; + const names = (message as Record).name; + if (!isArrayLike(names)) return []; + const out: string[] = []; + for (let i = 0; i < names.length; i++) { + const name = names[i]; + out.push(typeof name === 'string' && name.length > 0 ? name : `${i}`); + } + return out; +} + +/** `position[1-2]` — hyphen range (both ends inclusive, same as `position[1:2]`). */ +const SLICE_HYPHEN_RANGE_RE = /^(-?\d+)-(-?\d+)$/; +/** `position[2-]` — hyphen open end (same as `position[2:]`). */ +const SLICE_HYPHEN_START_RE = /^(-?\d+)-$/; + +function tryParseSliceSelector(selector: string): { start?: number; end?: number } | null { + if (selector === '' || selector === ':' || selector === '-') { + return { start: undefined, end: undefined }; + } + if (selector.includes(':')) { + const [startRaw, endRaw] = selector.split(':', 2); + const start = startRaw ? Number(startRaw) : undefined; + const end = endRaw ? Number(endRaw) : undefined; + return { + start: Number.isFinite(start) ? start : undefined, + end: Number.isFinite(end) ? end : undefined, + }; + } + const hyphenRange = SLICE_HYPHEN_RANGE_RE.exec(selector); + if (hyphenRange) { + const start = Number(hyphenRange[1]); + const end = Number(hyphenRange[2]); + if (Number.isFinite(start) && Number.isFinite(end)) { + return { start, end }; + } + } + const hyphenStart = SLICE_HYPHEN_START_RE.exec(selector); + if (hyphenStart) { + const start = Number(hyphenStart[1]); + if (Number.isFinite(start)) return { start, end: undefined }; + } + return null; +} + +function parseSelector(raw: string | undefined): Selector { + if (raw == null) return { kind: 'none' }; + const selector = raw.trim(); + const slice = tryParseSliceSelector(selector); + if (slice) { + return { kind: 'slice', start: slice.start, end: slice.end }; + } + const index = Number(selector); + if (Number.isInteger(index)) return { kind: 'index', index }; + return { kind: 'name', name: selector.replace(/^['"]|['"]$/g, '') }; +} + +function parseSegments(path: string): Segment[] { + if (!path) return []; + return path + .split('.') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const match = SEGMENT_RE.exec(part); + if (!match) { + throw new Error(`Unsupported plot path segment: ${part}`); + } + return { + field: match[1] ?? '', + selector: parseSelector(match[2]), + }; + }); +} + +function normalizeIndex(index: number, length: number): number | undefined { + const normalized = index < 0 ? length + index : index; + return normalized >= 0 && normalized < length ? normalized : undefined; +} + +/** Foxglove-style slice bounds: both ends inclusive when specified. */ +function resolveSliceBounds( + selector: { start?: number; end?: number }, + length: number, +): { startIdx: number; endIdx: number } | null { + if (length === 0) return null; + + const resolveBound = (index: number | undefined, fallback: number): number => { + if (index === undefined) return fallback; + const normalized = index < 0 ? length + index : index; + if (normalized < 0) return -1; + return Math.min(length - 1, normalized); + }; + + const startIdx = resolveBound(selector.start, 0); + const endIdx = resolveBound(selector.end, length - 1); + if (startIdx < 0 || endIdx < 0 || startIdx > endIdx) return null; + return { startIdx, endIdx }; +} + +function selectorItems( + value: unknown, + selector: Selector, + message: unknown, + field: string, +): Array<{ key: string; label: string; value: unknown }> { + if (selector.kind === 'none') return [{ key: field, label: field, value }]; + if (!isArrayLike(value)) return []; + + if (selector.kind === 'index') { + const index = normalizeIndex(selector.index, value.length); + return index == null ? [] : [{ key: `${field}[${index}]`, label: `${field}[${index}]`, value: value[index] }]; + } + + if (selector.kind === 'name') { + const names = readNames(message); + const index = names.indexOf(selector.name); + return index < 0 || index >= value.length + ? [] + : [{ + key: `${field}[${selector.name}]`, + label: `${field}[${index}] (${selector.name})`, + value: value[index], + }]; + } + + const bounds = resolveSliceBounds(selector, value.length); + if (!bounds) return []; + const { startIdx, endIdx } = bounds; + const names = readNames(message); + const out: Array<{ key: string; label: string; value: unknown }> = []; + for (let i = startIdx; i <= endIdx; i++) { + const name = names[i]; + const label = name ? `${field}[${i}] (${name})` : `${field}[${i}]`; + const key = name ? `${field}[${name}]` : `${field}[${i}]`; + out.push({ key, label, value: value[i] }); + } + return out; +} + +function toNumericValue(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + if (typeof value === 'bigint') return Number(value); + if (typeof value === 'boolean') return value ? 1 : 0; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + if (value && typeof value === 'object') { + const record = value as Partial