diff --git a/package-lock.json b/package-lock.json index c085964..569abf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ioai/rosview", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ioai/rosview", - "version": "2.2.0", + "version": "2.3.0", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", @@ -75,7 +75,7 @@ "typescript": "6.0.3", "typescript-eslint": "^8.67.0", "uplot": "^1.6.31", - "vite": "^8.2.1", + "vite": "^8.3.4", "vitest": "^4.1.11", "zustand": "^5.0.15" }, @@ -1038,9 +1038,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", "dev": true, "license": "MIT", "funding": { @@ -1943,9 +1943,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", "cpu": [ "arm" ], @@ -1960,9 +1960,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", "cpu": [ "arm64" ], @@ -1977,9 +1977,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", "cpu": [ "arm64" ], @@ -1994,9 +1994,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", "cpu": [ "x64" ], @@ -2011,9 +2011,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", "cpu": [ "x64" ], @@ -2028,9 +2028,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", "cpu": [ "arm" ], @@ -2045,9 +2045,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", "cpu": [ "arm64" ], @@ -2065,9 +2065,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", "cpu": [ "arm64" ], @@ -2085,9 +2085,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", "cpu": [ "ppc64" ], @@ -2105,9 +2105,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", "cpu": [ "s390x" ], @@ -2125,9 +2125,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", "cpu": [ "x64" ], @@ -2145,9 +2145,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", "cpu": [ "x64" ], @@ -2165,9 +2165,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", "cpu": [ "arm64" ], @@ -2182,9 +2182,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", "cpu": [ "arm64" ], @@ -2199,9 +2199,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", "cpu": [ "x64" ], @@ -5236,9 +5236,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -5406,9 +5406,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "dev": true, "funding": [ { @@ -5426,9 +5426,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5649,13 +5649,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5665,21 +5665,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" } }, "node_modules/scheduler": { @@ -6129,16 +6129,16 @@ } }, "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/package.json b/package.json index 4723347..4feb8d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ioai/rosview", - "version": "2.2.0", + "version": "2.3.0", "description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA", "keywords": [ "ros", @@ -145,7 +145,7 @@ "typescript": "6.0.3", "typescript-eslint": "^8.67.0", "uplot": "^1.6.31", - "vite": "^8.2.1", + "vite": "^8.3.4", "vitest": "^4.1.11", "zustand": "^5.0.15" } diff --git a/src/features/panels/Align/definition.tsx b/src/features/panels/Align/definition.tsx index 37f7904..aad7fd9 100644 --- a/src/features/panels/Align/definition.tsx +++ b/src/features/panels/Align/definition.tsx @@ -12,7 +12,7 @@ const AlignPanel = lazy(async () => { export const alignPanelDefinition: PanelDefinition = { type: 'Align', - defaultTitle: 'Align', + defaultTitle: 'Time alignment', createDefaultConfig: defaultAlignConfig, configSchema: { version: 1, parse: parseAlignConfig }, render: ({ player, panelId, config, setConfig }) => ( diff --git a/src/features/panels/Plot/PlotChartLegend.tsx b/src/features/panels/Plot/PlotChartLegend.tsx index d5f0ebd..c34eaa4 100644 --- a/src/features/panels/Plot/PlotChartLegend.tsx +++ b/src/features/panels/Plot/PlotChartLegend.tsx @@ -12,6 +12,7 @@ import { import { usePlotLegendEntries, type PlotLegendEntry } from './plotPanelRuntimeStore'; const COLLAPSED_LIMIT = 1; +const SEARCH_THRESHOLD = 8; interface PlotChartLegendProps { panelId: string; @@ -110,15 +111,11 @@ export function PlotChartLegend({ }: PlotChartLegendProps): React.ReactNode { const { formatMessage } = useIntl(); const entries = usePlotLegendEntries(panelId); - const [expanded, setExpanded] = useState(false); + const [open, setOpen] = useState(true); const [query, setQuery] = useState(''); const filtered = useMemo(() => filterPlotLegendEntries(entries, query), [entries, query]); const hiddenKeys = config.hiddenLegendKeys; - const collapsedEntries = useMemo( - () => collapsedPlotLegendEntries(entries, hiddenKeys), - [entries, hiddenKeys], - ); const allKeys = useMemo(() => entries.map((entry) => entry.key), [entries]); const visibleCount = visiblePlotLegendCount(entries, hiddenKeys); @@ -136,17 +133,18 @@ export function PlotChartLegend({ setHiddenKeys(setOnlyPlotLegendVisible(hiddenKeys, allKeys, entry.key)); }; + const showTools = entries.length > SEARCH_THRESHOLD; + return (
-
+
{formatMessage( { id: 'panels.plot.legend.visibleCount' }, @@ -155,48 +153,46 @@ export function PlotChartLegend({ + +
- - {expanded ? ( + {open ? ( <> -
- setQuery(event.target.value)} - className="h-7 w-full rounded border border-border bg-background px-2 text-[11px] outline-hidden focus:border-primary" - placeholder={formatMessage({ id: 'panels.plot.legend.searchPlaceholder' })} - aria-label={formatMessage({ id: 'panels.plot.legend.searchPlaceholder' })} - /> -
- - + {showTools && ( +
+ setQuery(event.target.value)} + className="h-6 w-full rounded border border-border bg-background px-2 text-[11px] outline-hidden focus:border-primary" + placeholder={formatMessage({ id: 'panels.plot.legend.searchPlaceholder' })} + aria-label={formatMessage({ id: 'panels.plot.legend.searchPlaceholder' })} + />
-
-
+ )} +
{filtered.length === 0 ? (
{formatMessage({ id: 'panels.plot.legend.noMatches' })} @@ -215,18 +211,7 @@ export function PlotChartLegend({ )}
- ) : ( -
- {collapsedEntries.map((entry) => ( - toggleEntry(entry)} - /> - ))} -
- )} + ) : null}
); } diff --git a/src/features/panels/Plot/PlotPanel.tsx b/src/features/panels/Plot/PlotPanel.tsx index 3d8a8d0..94d790d 100644 --- a/src/features/panels/Plot/PlotPanel.tsx +++ b/src/features/panels/Plot/PlotPanel.tsx @@ -27,6 +27,8 @@ import { setPlotLegendEntries, } from './plotPanelRuntimeStore'; import { PlotChartLegend } from './PlotChartLegend'; +import { displaySeriesLabels } from './plotSeriesDisplay'; +import { annotatePlotUnits } from './plotUnits'; import { formatPlotDatasetWarning } from './plotWarnings'; import { hasManualPlotViewport, usePlotChart, type PlotViewportState } from './usePlotChart'; import { usePlotPanelData } from './usePlotPanelData'; @@ -121,7 +123,7 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s endTime, }); - const { dataset, loading, progress, error } = usePlotPanelData({ + const { dataset: rawDataset, loading, progress, error } = usePlotPanelData({ player, config, activeTopics, @@ -131,6 +133,11 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s randomAccessByTopic, }); + const dataset = useMemo( + () => annotatePlotUnits(rawDataset, config.series, topicByName), + [rawDataset, config.series, topicByName], + ); + const hiddenSeries = useMemo( () => hiddenSeriesIndices(dataset.series, config.hiddenLegendKeys), [config.hiddenLegendKeys, dataset.series], @@ -162,9 +169,10 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s }, [player, panelId, activeTopics]); useEffect(() => { - const entries = dataset.series.map((series) => ({ + const displayLabels = displaySeriesLabels(dataset.series.map((series) => series.label)); + const entries = dataset.series.map((series, index) => ({ key: series.key, - label: series.label, + label: displayLabels[index] ?? series.label, color: series.color, })); setPlotLegendEntries(panelId, entries); diff --git a/src/features/panels/Plot/plotChart.ts b/src/features/panels/Plot/plotChart.ts index 82e4c93..a44044d 100644 --- a/src/features/panels/Plot/plotChart.ts +++ b/src/features/panels/Plot/plotChart.ts @@ -4,6 +4,7 @@ import type { Time } from '@/core/types/ros'; import { formatDurationNs } from '@/shared/utils/time'; import type { PlotDataset, PlotRuntimeSeries } from './datasets'; import type { PlotXAxisMode } from './defaults'; +import { buildPlotTooltipRows, type PlotTooltipSeries } from './plotSeriesDisplay'; export interface PlotChartColors { axisStroke: string; @@ -215,8 +216,11 @@ export function computeRelativeTimeSplits( interface CursorLabelElements { x: HTMLDivElement; y: HTMLDivElement; + tip: HTMLDivElement; } +const TOOLTIP_MAX_ROWS = 16; + const cursorLabelsByChart = new WeakMap(); function applyCursorLabelStyle(el: HTMLDivElement, colors: PlotChartColors): void { @@ -243,9 +247,15 @@ function initCursorLabels(u: uPlot, colors: PlotChartColors): void { applyCursorLabelStyle(xLabel, colors); applyCursorLabelStyle(yLabel, colors); + const tip = document.createElement('div'); + applyCursorLabelStyle(tip, colors); + tip.style.padding = '4px 6px'; + tip.dataset.testid = 'plot-hover-tooltip'; + u.over.appendChild(xLabel); u.over.appendChild(yLabel); - cursorLabelsByChart.set(u, { x: xLabel, y: yLabel }); + u.over.appendChild(tip); + cursorLabelsByChart.set(u, { x: xLabel, y: yLabel, tip }); } function hideCursorLabels(u: uPlot): void { @@ -253,12 +263,14 @@ function hideCursorLabels(u: uPlot): void { if (!labels) return; labels.x.style.display = 'none'; labels.y.style.display = 'none'; + labels.tip.style.display = 'none'; } function updateCursorLabels( u: uPlot, xAxisMode: PlotXAxisMode, logStart: Time | undefined, + getTooltipSeries?: () => PlotTooltipSeries[], ): void { const labels = cursorLabelsByChart.get(u); if (!labels) return; @@ -287,6 +299,69 @@ function updateCursorLabels( labels.y.style.left = '4px'; labels.y.style.top = `${cy}px`; labels.y.style.transform = 'translateY(-50%)'; + + updateHoverTooltip(u, labels.tip, cx, cy, plotW, xAxisMode, logStart, getTooltipSeries); +} + +function updateHoverTooltip( + u: uPlot, + tip: HTMLDivElement, + cx: number, + cy: number, + plotW: number, + xAxisMode: PlotXAxisMode, + logStart: Time | undefined, + getTooltipSeries?: () => PlotTooltipSeries[], +): void { + const idx = u.cursor.idx; + if (!getTooltipSeries || idx == null) { + tip.style.display = 'none'; + return; + } + const meta = getTooltipSeries(); + const shown = meta.map((entry, i) => ({ ...entry, show: entry.show && u.series[i + 1]?.show !== false })); + const rows = buildPlotTooltipRows(u.data as ArrayLike[], idx, shown); + if (rows.length === 0) { + tip.style.display = 'none'; + return; + } + tip.replaceChildren(); + const xVal = u.data[0]?.[idx]; + if (xVal != null) { + const head = document.createElement('div'); + head.style.opacity = '0.7'; + head.textContent = formatPlotXValue(xVal, xAxisMode, logStart); + tip.appendChild(head); + } + for (const row of rows.slice(0, TOOLTIP_MAX_ROWS)) { + const line = document.createElement('div'); + line.style.display = 'flex'; + line.style.alignItems = 'center'; + line.style.gap = '6px'; + const swatch = document.createElement('span'); + swatch.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:2px;flex:none;background:${row.color}`; + const name = document.createElement('span'); + name.textContent = row.label; + name.style.flex = '1'; + const val = document.createElement('span'); + val.textContent = row.value; + val.style.fontVariantNumeric = 'tabular-nums'; + line.append(swatch, name, val); + tip.appendChild(line); + } + if (rows.length > TOOLTIP_MAX_ROWS) { + const more = document.createElement('div'); + more.style.opacity = '0.7'; + more.textContent = `+${rows.length - TOOLTIP_MAX_ROWS}`; + tip.appendChild(more); + } + tip.style.display = 'block'; + // Flip to the left of the cursor when the right side would overflow the plot. + const width = tip.offsetWidth; + const flip = cx + 12 + width > plotW && cx - 12 - width > 0; + tip.style.left = `${flip ? cx - 12 - width : cx + 12}px`; + const maxTop = Math.max(0, u.bbox.height - tip.offsetHeight); + tip.style.top = `${Math.min(Math.max(0, cy - tip.offsetHeight / 2), maxTop)}px`; } function destroyCursorLabels(u: uPlot): void { @@ -294,6 +369,7 @@ function destroyCursorLabels(u: uPlot): void { if (!labels) return; labels.x.remove(); labels.y.remove(); + labels.tip.remove(); cursorLabelsByChart.delete(u); } @@ -336,6 +412,8 @@ export interface PlotChartBuildContext { * behaviour resumes on the next setData/redraw. */ isLoading?: () => boolean; + /** Current series labels/colors/units for the hover tooltip (read on every cursor move). */ + getTooltipSeries?: () => PlotTooltipSeries[]; } export type PlotUplotOptions = Omit; @@ -410,7 +488,7 @@ export function createPlotUplotOptions( hiddenSeries: Set, ctx: PlotChartBuildContext, ): PlotUplotOptions { - const { colors, xAxisMode, xRange, logStart, getCurrentTimeSec, panelId } = ctx; + const { colors, xAxisMode, xRange, logStart, getCurrentTimeSec, panelId, getTooltipSeries } = ctx; const useRelativeTimeAxis = xAxisMode === 'timestamp' && logStart != null; const logStartSec = useRelativeTimeAxis ? timeToSec(logStart) : undefined; @@ -446,6 +524,9 @@ export function createPlotUplotOptions( grid: { show: true, stroke: colors.gridStroke, width: 1 }, stroke: colors.axisStroke, font: '10px sans-serif', + ...(dataset.yUnit + ? { label: dataset.yUnit, labelSize: 14, labelFont: '10px sans-serif', size: 56 } + : {}), }, ], cursor: { @@ -467,7 +548,7 @@ export function createPlotUplotOptions( }, hooks: { ready: [(u) => initCursorLabels(u, colors)], - setCursor: [(u) => updateCursorLabels(u, xAxisMode, logStart)], + setCursor: [(u) => updateCursorLabels(u, xAxisMode, logStart, getTooltipSeries)], destroy: [(u) => destroyCursorLabels(u)], draw: [(u) => drawPlayhead(u, getCurrentTimeSec, colors.playheadStroke)], }, diff --git a/src/features/panels/Plot/plotSeriesDisplay.test.ts b/src/features/panels/Plot/plotSeriesDisplay.test.ts new file mode 100644 index 0000000..f231ffa --- /dev/null +++ b/src/features/panels/Plot/plotSeriesDisplay.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { buildPlotTooltipRows, displaySeriesLabels, formatPlotValue } from './plotSeriesDisplay'; + +describe('displaySeriesLabels', () => { + it('uses joint names when one field is plotted', () => { + expect(displaySeriesLabels(['position[0] (proximal_pitch)', 'position[1] (elbow)'])).toEqual([ + 'proximal_pitch', + 'elbow', + ]); + }); + it('appends the field when several fields are plotted', () => { + expect(displaySeriesLabels(['position[0] (a)', 'velocity[0] (a)'])).toEqual([ + 'a · position', + 'a · velocity', + ]); + }); + it('leaves unnamed labels untouched', () => { + expect(displaySeriesLabels(['position[0]', '/t · x'])).toEqual(['position[0]', '/t · x']); + }); +}); + +describe('formatPlotValue', () => { + it('limits precision and switches to exponent for extremes', () => { + expect(formatPlotValue(1.234567)).toBe('1.2346'); + expect(formatPlotValue(0)).toBe('0'); + expect(formatPlotValue(0.00001234)).toBe('1.234e-5'); + }); +}); + +describe('buildPlotTooltipRows', () => { + const series = [ + { label: 'a', color: '#1', unit: 'rad', show: true }, + { label: 'b', color: '#2', show: false }, + { label: 'c', color: '#3', show: true }, + ]; + it('lists visible series with values and units, skipping gaps', () => { + const data = [[0, 1], [0.5, 1.5], [9, 9], [null, 2]]; + expect(buildPlotTooltipRows(data, 0, series)).toEqual([{ color: '#1', label: 'a', value: '0.5 rad' }]); + expect(buildPlotTooltipRows(data, 1, series)).toEqual([ + { color: '#1', label: 'a', value: '1.5 rad' }, + { color: '#3', label: 'c', value: '2' }, + ]); + }); +}); diff --git a/src/features/panels/Plot/plotSeriesDisplay.ts b/src/features/panels/Plot/plotSeriesDisplay.ts new file mode 100644 index 0000000..64a9cf2 --- /dev/null +++ b/src/features/panels/Plot/plotSeriesDisplay.ts @@ -0,0 +1,58 @@ +const NAMED_ITEM = /^([A-Za-z_]\w*)\[\d+\] \((.+)\)$/; + +/** + * Human labels for series. Array items carry `field[i] (name)`; the joint name + * is what users recognise, so use it alone when all series share one field and + * append the field (`name · velocity`) when several fields are plotted together. + */ +export function displaySeriesLabels(labels: readonly string[]): string[] { + const parsed = labels.map((label) => NAMED_ITEM.exec(label)); + const fields = new Set(); + for (const match of parsed) if (match) fields.add(match[1]); + return labels.map((label, index) => { + const match = parsed[index]; + if (!match) return label; + return fields.size > 1 ? `${match[2]} · ${match[1]}` : match[2]; + }); +} + +export function formatPlotValue(value: number): string { + if (!Number.isFinite(value)) return String(value); + const abs = Math.abs(value); + if (abs !== 0 && (abs >= 1e6 || abs < 1e-4)) return value.toExponential(3); + return Number(value.toPrecision(5)).toString(); +} + +export interface PlotTooltipRow { + color: string; + label: string; + value: string; +} + +export interface PlotTooltipSeries { + label: string; + color: string; + unit?: string; + show: boolean; +} + +/** Rows (series name + value) at data index `idx`; hidden series and gaps are skipped. */ +export function buildPlotTooltipRows( + data: ReadonlyArray>, + idx: number, + series: readonly PlotTooltipSeries[], +): PlotTooltipRow[] { + const rows: PlotTooltipRow[] = []; + series.forEach((entry, i) => { + if (!entry.show) return; + const value = data[i + 1]?.[idx]; + if (value == null || !Number.isFinite(value)) return; + const text = formatPlotValue(value); + rows.push({ + color: entry.color, + label: entry.label, + value: entry.unit ? `${text} ${entry.unit}` : text, + }); + }); + return rows; +} diff --git a/src/features/panels/Plot/plotUnits.test.ts b/src/features/panels/Plot/plotUnits.test.ts new file mode 100644 index 0000000..6200a5b --- /dev/null +++ b/src/features/panels/Plot/plotUnits.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { annotatePlotUnits, combinePlotUnits, inferPlotUnit } from './plotUnits'; +import type { PlotDataset } from './types'; + +describe('inferPlotUnit', () => { + it('maps JointState fields', () => { + expect(inferPlotUnit('sensor_msgs/msg/JointState', 'position[:]')).toBe('rad'); + expect(inferPlotUnit('sensor_msgs/JointState', 'velocity[0]')).toBe('rad/s'); + expect(inferPlotUnit('sensor_msgs/JointState', 'effort[:]')).toBe('N·m'); + }); + it('maps pose positions and twists', () => { + expect(inferPlotUnit('geometry_msgs/PoseStamped', 'pose.position.x')).toBe('m'); + expect(inferPlotUnit('geometry_msgs/PoseStamped', 'pose.orientation.w')).toBeUndefined(); + expect(inferPlotUnit('geometry_msgs/Twist', 'linear.x')).toBe('m/s'); + expect(inferPlotUnit('geometry_msgs/Twist', 'angular.z')).toBe('rad/s'); + }); + it('is unknown for other schemas and derivatives', () => { + expect(inferPlotUnit('std_msgs/Float32', 'data')).toBeUndefined(); + expect(inferPlotUnit(undefined, 'position[:]')).toBeUndefined(); + expect(inferPlotUnit('sensor_msgs/JointState', 'position[0]@derivative')).toBeUndefined(); + }); +}); + +describe('combinePlotUnits', () => { + it('joins distinct units and drops the label when any is unknown', () => { + expect(combinePlotUnits(['rad', 'rad'])).toBe('rad'); + expect(combinePlotUnits(['rad', 'rad/s'])).toBe('rad, rad/s'); + expect(combinePlotUnits(['rad', undefined])).toBeUndefined(); + expect(combinePlotUnits([])).toBeUndefined(); + }); +}); + +describe('annotatePlotUnits', () => { + const base: PlotDataset = { + xLabel: 'time', + series: [{ key: 's1:position[a]', label: 'x', color: '#1', lineStyle: 'solid', lineSize: 1, enabled: true }], + data: [[0], [1]], + pointCount: 1, + sampleRatio: 1, + warnings: [], + }; + it('fills unit and yUnit from topic schema', () => { + const out = annotatePlotUnits(base, [{ id: 's1', topic: '/js', path: 'position[:]' }], new Map([['/js', { type: 'sensor_msgs/msg/JointState' }]])); + expect(out.yUnit).toBe('rad'); + expect(out.series[0].unit).toBe('rad'); + }); + it('returns the same dataset when nothing is known', () => { + expect(annotatePlotUnits(base, [{ id: 's1', topic: '/x', path: 'data' }], new Map())).toBe(base); + }); +}); diff --git a/src/features/panels/Plot/plotUnits.ts b/src/features/panels/Plot/plotUnits.ts new file mode 100644 index 0000000..296ee1d --- /dev/null +++ b/src/features/panels/Plot/plotUnits.ts @@ -0,0 +1,93 @@ +import type { TopicInfo } from '@/core/types/ros'; +import { hasDerivativeModifier } from './messagePath'; +import type { PlotDataset, PlotRuntimeSeries } from './types'; + +/** `sensor_msgs/msg/JointState` and `sensor_msgs/JointState` both normalize to `sensor_msgs/JointState`. */ +function normalizeSchemaName(schema: string): string { + return schema.replace(/\/msg\//, '/'); +} + +const JOINT_STATE_UNITS: Record = { + position: 'rad', + velocity: 'rad/s', + effort: 'N·m', +}; + +const POSE_LIKE_SCHEMAS = new Set([ + 'geometry_msgs/Pose', + 'geometry_msgs/PoseStamped', + 'geometry_msgs/PoseWithCovariance', + 'geometry_msgs/PoseWithCovarianceStamped', + 'geometry_msgs/Point', + 'geometry_msgs/PointStamped', + 'geometry_msgs/Transform', + 'geometry_msgs/TransformStamped', + 'nav_msgs/Odometry', +]); + +/** + * Infer the physical unit of a plot path from the message schema, or + * `undefined` when unknown. Joint fields assume revolute joints (the ROS + * convention for JointState); derivative paths are left unlabeled because the + * modifier changes the unit. + */ +export function inferPlotUnit(schemaName: string | undefined, path: string): string | undefined { + if (!schemaName || hasDerivativeModifier(path)) return undefined; + const schema = normalizeSchemaName(schemaName); + const trimmed = path.trim().replace(/^\./, ''); + if (schema === 'sensor_msgs/JointState') { + const field = /^[A-Za-z_]+/.exec(trimmed)?.[0]; + return field ? JOINT_STATE_UNITS[field] : undefined; + } + if (POSE_LIKE_SCHEMAS.has(schema)) { + return /(^|\.)(position|translation)\.[xyz]\b/.test(trimmed) || /^[xyz]$/.test(trimmed) + ? 'm' + : undefined; + } + if (schema === 'geometry_msgs/Twist' || schema === 'geometry_msgs/TwistStamped') { + if (/(^|\.)linear\.[xyz]\b/.test(trimmed)) return 'm/s'; + if (/(^|\.)angular\.[xyz]\b/.test(trimmed)) return 'rad/s'; + } + return undefined; +} + +/** Y-axis label for a set of series units: one shared unit, several joined, or none. */ +export function combinePlotUnits(units: ReadonlyArray): string | undefined { + const distinct: string[] = []; + for (const unit of units) { + if (unit == null) return undefined; + if (!distinct.includes(unit)) distinct.push(unit); + } + return distinct.length > 0 ? distinct.join(', ') : undefined; +} + +interface UnitSeriesConfig { + id: string; + topic: string; + path: string; +} + +/** + * Return `dataset` with per-series `unit` and the shared `yUnit` filled in. + * The Y axis only carries a label when every series has a known unit. + */ +export function annotatePlotUnits( + dataset: PlotDataset, + configSeries: ReadonlyArray, + topicByName: ReadonlyMap>, +): PlotDataset { + if (dataset.series.length === 0) return dataset; + const byId = new Map(configSeries.map((s) => [s.id, s])); + let changed = false; + const series = dataset.series.map((entry): PlotRuntimeSeries => { + const sep = entry.key.indexOf(':'); + const cfg = byId.get(sep >= 0 ? entry.key.slice(0, sep) : entry.key); + const unit = cfg ? inferPlotUnit(topicByName.get(cfg.topic)?.type, cfg.path) : undefined; + if (unit === entry.unit) return entry; + changed = true; + return { ...entry, unit }; + }); + const yUnit = combinePlotUnits(series.map((s) => s.unit)); + if (!changed && yUnit === dataset.yUnit) return dataset; + return { ...dataset, series, yUnit }; +} diff --git a/src/features/panels/Plot/types.ts b/src/features/panels/Plot/types.ts index 93c9def..af162e5 100644 --- a/src/features/panels/Plot/types.ts +++ b/src/features/panels/Plot/types.ts @@ -10,6 +10,8 @@ export interface PlotRuntimeSeries { lineStyle: PlotLineStyle; lineSize: number; enabled: boolean; + /** Physical unit inferred from the message schema; undefined when unknown. */ + unit?: string; } export interface PlotDataset { @@ -20,6 +22,8 @@ export interface PlotDataset { /** Share of raw X-axis samples kept after downsampling; 1 when nothing was dropped. */ sampleRatio: number; warnings: PlotDatasetWarning[]; + /** Y-axis unit shared by every series; undefined when unknown or mixed-with-unknown. */ + yUnit?: string; } export interface BuildPlotDatasetOptions { diff --git a/src/features/panels/Plot/usePlotChart.ts b/src/features/panels/Plot/usePlotChart.ts index 35616b9..b7c1b31 100644 --- a/src/features/panels/Plot/usePlotChart.ts +++ b/src/features/panels/Plot/usePlotChart.ts @@ -6,6 +6,7 @@ import type { Time } from '@/core/types/ros'; import { scheduleFrame } from '@/shared/utils/rafScheduler'; import type { PlotDataset, PlotRuntimeSeries } from './datasets'; import type { PlotConfig } from './defaults'; +import { displaySeriesLabels, type PlotTooltipSeries } from './plotSeriesDisplay'; import { buildPlotSeriesOption, createPlotUplotOptions, @@ -240,6 +241,8 @@ export function usePlotChart({ const resizeObserverRef = useRef(null); const interactionCleanupRef = useRef<(() => void) | null>(null); const loadingRef = useRef(!!loading); + const mountedYUnitRef = useRef(undefined); + const tooltipSeriesRef = useRef([]); const manualViewportRef = useRef({ x: false, y: false }); const xRangeRef = useRef(xRange); const onViewportStateChangeRef = useRef(onViewportStateChange); @@ -424,7 +427,9 @@ export function usePlotChart({ getCurrentTimeSec: () => currentTimeSecRef.current, colors, isLoading: () => loadingRef.current, + getTooltipSeries: () => tooltipSeriesRef.current, }); + mountedYUnitRef.current = dataset.yUnit; const chart = mountPlotChart(container, dataset, options, xRange); uplotRef.current = chart; @@ -462,7 +467,16 @@ export function usePlotChart({ followingViewWidthRef.current = config.followingViewWidthSec; xAxisModeRef.current = config.xAxisMode; - if (!chart || xAxisModeChanged) { + const displayLabels = displaySeriesLabels(dataset.series.map((s) => s.label)); + tooltipSeriesRef.current = dataset.series.map((s, index) => ({ + label: displayLabels[index] ?? s.label, + color: s.color, + unit: s.unit, + show: !hiddenSeries.has(index), + })); + + // The axis title is part of the chart DOM; a unit change needs a remount. + if (!chart || xAxisModeChanged || mountedYUnitRef.current !== dataset.yUnit) { mountChart(); return; } diff --git a/src/features/panels/ThreeD/ThreeDPanel.tsx b/src/features/panels/ThreeD/ThreeDPanel.tsx index 60529eb..88379a7 100644 --- a/src/features/panels/ThreeD/ThreeDPanel.tsx +++ b/src/features/panels/ThreeD/ThreeDPanel.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; import type { Player } from '@/core/types/player'; import type { Time, TopicInfo } from '@/core/types/ros'; import type { MessagePipelineState } from '@/core/pipeline/store'; @@ -1858,6 +1859,7 @@ export const ThreeDPanel: React.FC = ({ urdf, topicSettings = [], }) => { + const { formatMessage } = useIntl(); const { resolvedTheme } = useRosViewTheme(); const colors = useMemo(() => getScenePanelThemeColors(resolvedTheme), [resolvedTheme]); const resolvedUrdf = urdf ?? defaultUrdfSource(); @@ -1879,7 +1881,7 @@ export const ThreeDPanel: React.FC = ({ > {isMeshLoading ? `Loading Mesh ${meshLoadProgress.loaded}/${meshLoadProgress.total} (${meshLoadPercent}%)` - : '3D View'} + : formatMessage({ id: 'panels.threeD.defaultTitle' })}
{ }); describe('CachedFilelike download progress', () => { - it('forwards stream progress to onDownloadProgress', async () => { + it('reports cumulative bytes against a stable file-size total across fetch blocks', async () => { const reader = new TestFileReader(); const updates: Array<{ loadedBytes: number; totalBytes: number; transferredBytes: number }> = []; const filelike = new CachedFilelike({ @@ -190,15 +190,24 @@ describe('CachedFilelike download progress', () => { onDownloadProgress: (info) => updates.push({ ...info }), }); - const readPromise = filelike.read(0, 8); + const first = filelike.read(0, 8); await flushAsyncWork(); - expect(reader.streams).toHaveLength(1); - - reader.streams[0].emitProgress(4, 8); + reader.streams[0].emitProgress(8, 8); reader.streams[0].emitData([0, 1, 2, 3, 4, 5, 6, 7]); - await readPromise; + await first; - expect(updates.some((update) => update.loadedBytes === 4 && update.totalBytes === 8)).toBe(true); + const second = filelike.read(8, 4); + await flushAsyncWork(); + // A per-request total would be 8 or 4 here; the file total must stay 32. + reader.streams[1].emitProgress(4, 4); + reader.streams[1].emitData([8, 9, 10, 11]); + await second; + + expect(updates.length).toBeGreaterThan(0); + expect(new Set(updates.map((u) => u.totalBytes))).toEqual(new Set([32])); + const loaded = updates.map((u) => u.loadedBytes); + expect([...loaded].sort((a, b) => a - b)).toEqual(loaded); + expect(loaded.at(-1)).toBe(12); expect(updates[0].transferredBytes).toBeGreaterThan(0); }); }); diff --git a/src/infra/services/CachedFilelike.ts b/src/infra/services/CachedFilelike.ts index 0b36a6c..a0bd7af 100644 --- a/src/infra/services/CachedFilelike.ts +++ b/src/infra/services/CachedFilelike.ts @@ -53,6 +53,7 @@ export default class CachedFilelike implements Readable { #onDownloadProgress?: (info: DownloadProgressInfo) => void; #transferredBytes = 0; #currentRequestReceived = 0; + #reportedLoadedBytes = 0; #currentConnection: { stream: FileStream; remainingRange: Range; kind: "read" | "prefetch" } | undefined; @@ -339,9 +340,8 @@ export default class CachedFilelike implements Readable { const stream = this.#fileReader.fetch(range.start, range.end - range.start); this.#currentConnection = { stream, remainingRange: range, kind }; this.#currentRequestReceived = 0; - const requestTotal = range.end - range.start; - stream.on("progress", (received: number, total: number) => { + stream.on("progress", (received: number) => { const currentConnection = this.#currentConnection; if (!currentConnection || stream !== currentConnection.stream) { return; @@ -352,7 +352,7 @@ export default class CachedFilelike implements Readable { this.#transferredBytes += delta; this.#currentRequestReceived = safeReceived; } - this.#reportDownloadProgress(safeReceived, total > 0 ? total : requestTotal); + this.#reportDownloadProgress(); }); stream.on("error", (error: Error) => { @@ -414,8 +414,8 @@ export default class CachedFilelike implements Readable { bytesRead += chunk.byteLength; if (this.#currentRequestReceived === 0 && chunk.byteLength > 0) { this.#transferredBytes += chunk.byteLength; - this.#reportDownloadProgress(bytesRead, requestTotal); } + this.#reportDownloadProgress(); if (this.#virtualBuffer.hasData(range.start, range.end)) { stream.destroy(); @@ -432,10 +432,23 @@ export default class CachedFilelike implements Readable { }); } - #reportDownloadProgress(loadedBytes: number, totalBytes: number): void { + /** + * Report file-level progress: bytes of the file already held in the cache + * against the (stable) file size. Per-request figures made the total drift + * between fetch blocks (e.g. 50.0 MB then 44.5 MB for the last block). When + * the file size is unknown the total is 0 so UIs show only bytes loaded. + */ + #reportDownloadProgress(): void { + const fileSize = this.#fileSize ?? 0; + let held = 0; + for (const range of this.#virtualBuffer.getRangesWithData()) { + held += range.end - range.start; + } + const capped = fileSize > 0 ? Math.min(fileSize, held) : held; + this.#reportedLoadedBytes = Math.max(this.#reportedLoadedBytes, capped); this.#onDownloadProgress?.({ - loadedBytes, - totalBytes, + loadedBytes: this.#reportedLoadedBytes, + totalBytes: fileSize, transferredBytes: this.#transferredBytes, }); } diff --git a/src/shared/intl/messages/en/panels.json b/src/shared/intl/messages/en/panels.json index 99f3274..a6fd7d0 100644 --- a/src/shared/intl/messages/en/panels.json +++ b/src/shared/intl/messages/en/panels.json @@ -82,7 +82,7 @@ "panels.rawMessages.defaultTitle": "Raw", "panels.timeline.defaultTitle": "Timeline", "panels.topicGraph.defaultTitle": "Topic Graph", - "panels.align.defaultTitle": "Align", + "panels.align.defaultTitle": "Time alignment", "panels.unavailable.defaultTitle": "Unavailable", "panels.urdfDebug.defaultTitle": "URDF Debug", "panels.jointStatePlot.fallback.missingHeaderStamp": "header.stamp missing; fell back to receiveTime", diff --git a/src/shared/intl/messages/ja/panels.json b/src/shared/intl/messages/ja/panels.json index 6c7f035..69dce39 100644 --- a/src/shared/intl/messages/ja/panels.json +++ b/src/shared/intl/messages/ja/panels.json @@ -82,7 +82,7 @@ "panels.rawMessages.defaultTitle": "生メッセージ", "panels.timeline.defaultTitle": "タイムライン", "panels.topicGraph.defaultTitle": "トピックグラフ", - "panels.align.defaultTitle": "整列", + "panels.align.defaultTitle": "時間整列", "panels.unavailable.defaultTitle": "利用不可", "panels.urdfDebug.defaultTitle": "URDF デバッグ", "panels.jointStatePlot.fallback.missingHeaderStamp": "header.stamp がありません。receiveTime にフォールバックしました", diff --git a/src/shared/intl/messages/zh/panels.json b/src/shared/intl/messages/zh/panels.json index 1ff844b..9d02e27 100644 --- a/src/shared/intl/messages/zh/panels.json +++ b/src/shared/intl/messages/zh/panels.json @@ -76,13 +76,13 @@ "panels.plot.settings.enum.lineStyle.solid": "实线", "panels.plot.settings.enum.lineStyle.dashed": "虚线", "panels.jointStatePlot.defaultTitle": "关节状态图", - "panels.threeD.defaultTitle": "三维", + "panels.threeD.defaultTitle": "3D 视图", "panels.audio.defaultTitle": "音频", "panels.pose.defaultTitle": "位姿", "panels.rawMessages.defaultTitle": "原始消息", "panels.timeline.defaultTitle": "时间线", "panels.topicGraph.defaultTitle": "话题图", - "panels.align.defaultTitle": "对齐", + "panels.align.defaultTitle": "时间对齐", "panels.unavailable.defaultTitle": "不可用", "panels.urdfDebug.defaultTitle": "URDF 调试", "panels.jointStatePlot.fallback.missingHeaderStamp": "header.stamp 缺失,已回退到 receiveTime",