From 1f52f687d4e3d8c616a78f4271e383c680c32e9e Mon Sep 17 00:00:00 2001 From: joaner Date: Wed, 27 May 2026 15:27:47 +0800 Subject: [PATCH 01/13] feat(vite): add custom chunk file naming and adjust asset directory structure Implement a new function for generating chunk file names for lazy panel UI components, ensuring a consistent naming convention. Additionally, modify the output configuration to place assets directly in the root of the dist-lib directory, streamlining the build output structure. --- vite.lib.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vite.lib.config.ts b/vite.lib.config.ts index 4544396..5476ea1 100644 --- a/vite.lib.config.ts +++ b/vite.lib.config.ts @@ -77,6 +77,8 @@ export default defineConfig({ build: { target: 'esnext', outDir: 'dist-lib', + /** Flat dist-lib: WASM/HDF5 assets alongside chunks (no dist-lib/assets/). */ + assetsDir: '.', /** Library + worker chunks: no .map in dist-lib (smaller publish / vendored copy). */ sourcemap: false, copyPublicDir: false, @@ -103,7 +105,7 @@ export default defineConfig({ output: { assetFileNames: (assetInfo) => { if (assetInfo.name?.endsWith('.css')) return 'rosview.css'; - return assetInfo.name || '[name][extname]'; + return '[name]-[hash][extname]'; }, }, }, From 0aa340987d53c5efc5cc89198f1583a208135c86 Mon Sep 17 00:00:00 2001 From: joaner Date: Wed, 27 May 2026 15:41:20 +0800 Subject: [PATCH 02/13] refactor(panels): rename lazy-load targets from Component to Panel files Align panel chunk naming with ThreeD/Pose so dist-lib outputs identifiable {PanelName}Panel-*.js chunks instead of generic Component-*.js files. --- src/features/panels/Align/{Component.tsx => AlignPanel.tsx} | 0 src/features/panels/Align/definition.tsx | 2 +- src/features/panels/Audio/{Component.tsx => AudioPanel.tsx} | 0 src/features/panels/Audio/definition.tsx | 2 +- src/features/panels/Image/{Component.tsx => ImagePanel.tsx} | 0 src/features/panels/Image/definition.tsx | 2 +- .../JointStatePlot/{Component.tsx => JointStatePlotPanel.tsx} | 0 src/features/panels/JointStatePlot/definition.tsx | 2 +- .../panels/RawMessages/{Component.tsx => RawMessagesPanel.tsx} | 0 src/features/panels/RawMessages/definition.tsx | 2 +- src/features/panels/RawMessages/describeValue.test.ts | 2 +- .../panels/Timeline/{Component.tsx => TimelinePanel.tsx} | 0 src/features/panels/Timeline/definition.tsx | 2 +- .../panels/TopicGraph/{Component.tsx => TopicGraphPanel.tsx} | 0 src/features/panels/TopicGraph/definition.tsx | 2 +- .../panels/UrdfDebug/{Component.tsx => UrdfDebugPanel.tsx} | 0 src/features/panels/UrdfDebug/definition.tsx | 2 +- 17 files changed, 9 insertions(+), 9 deletions(-) rename src/features/panels/Align/{Component.tsx => AlignPanel.tsx} (100%) rename src/features/panels/Audio/{Component.tsx => AudioPanel.tsx} (100%) rename src/features/panels/Image/{Component.tsx => ImagePanel.tsx} (100%) rename src/features/panels/JointStatePlot/{Component.tsx => JointStatePlotPanel.tsx} (100%) rename src/features/panels/RawMessages/{Component.tsx => RawMessagesPanel.tsx} (100%) rename src/features/panels/Timeline/{Component.tsx => TimelinePanel.tsx} (100%) rename src/features/panels/TopicGraph/{Component.tsx => TopicGraphPanel.tsx} (100%) rename src/features/panels/UrdfDebug/{Component.tsx => UrdfDebugPanel.tsx} (100%) diff --git a/src/features/panels/Align/Component.tsx b/src/features/panels/Align/AlignPanel.tsx similarity index 100% rename from src/features/panels/Align/Component.tsx rename to src/features/panels/Align/AlignPanel.tsx 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/Audio/Component.tsx b/src/features/panels/Audio/AudioPanel.tsx similarity index 100% rename from src/features/panels/Audio/Component.tsx rename to src/features/panels/Audio/AudioPanel.tsx 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 100% rename from src/features/panels/Image/Component.tsx rename to src/features/panels/Image/ImagePanel.tsx 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/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..e442409 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 }; }); diff --git a/src/features/panels/RawMessages/Component.tsx b/src/features/panels/RawMessages/RawMessagesPanel.tsx similarity index 100% rename from src/features/panels/RawMessages/Component.tsx rename to src/features/panels/RawMessages/RawMessagesPanel.tsx diff --git a/src/features/panels/RawMessages/definition.tsx b/src/features/panels/RawMessages/definition.tsx index 01c7b3a..89e28b9 100644 --- a/src/features/panels/RawMessages/definition.tsx +++ b/src/features/panels/RawMessages/definition.tsx @@ -6,7 +6,7 @@ import { parseRawMessagesConfig } from './schema'; import { RawMessagesPanelSettings } from './RawMessagesPanelSettings'; const RawMessagesPanel = lazy(async () => { - const m = await import('./Component'); + const m = await import('./RawMessagesPanel'); return { default: m.RawMessagesPanel }; }); diff --git a/src/features/panels/RawMessages/describeValue.test.ts b/src/features/panels/RawMessages/describeValue.test.ts index a89821e..6c474ca 100644 --- a/src/features/panels/RawMessages/describeValue.test.ts +++ b/src/features/panels/RawMessages/describeValue.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { describeValue } from './Component'; +import { describeValue } from './RawMessagesPanel'; describe('describeValue', () => { it('shows hex preview for small binary buffers', () => { diff --git a/src/features/panels/Timeline/Component.tsx b/src/features/panels/Timeline/TimelinePanel.tsx similarity index 100% rename from src/features/panels/Timeline/Component.tsx rename to src/features/panels/Timeline/TimelinePanel.tsx diff --git a/src/features/panels/Timeline/definition.tsx b/src/features/panels/Timeline/definition.tsx index 78761d4..7280d39 100644 --- a/src/features/panels/Timeline/definition.tsx +++ b/src/features/panels/Timeline/definition.tsx @@ -6,7 +6,7 @@ import { parseTimelineConfig } from './schema'; import { TimelinePanelSettings } from './TimelinePanelSettings'; const TimelinePanel = lazy(async () => { - const m = await import('./Component'); + const m = await import('./TimelinePanel'); return { default: m.TimelinePanel }; }); diff --git a/src/features/panels/TopicGraph/Component.tsx b/src/features/panels/TopicGraph/TopicGraphPanel.tsx similarity index 100% rename from src/features/panels/TopicGraph/Component.tsx rename to src/features/panels/TopicGraph/TopicGraphPanel.tsx diff --git a/src/features/panels/TopicGraph/definition.tsx b/src/features/panels/TopicGraph/definition.tsx index e362720..302a652 100644 --- a/src/features/panels/TopicGraph/definition.tsx +++ b/src/features/panels/TopicGraph/definition.tsx @@ -6,7 +6,7 @@ import { parseTopicGraphConfig } from './schema'; import { TopicGraphPanelSettings } from './TopicGraphPanelSettings'; const TopicGraphPanel = lazy(async () => { - const m = await import('./Component'); + const m = await import('./TopicGraphPanel'); return { default: m.TopicGraphPanel }; }); diff --git a/src/features/panels/UrdfDebug/Component.tsx b/src/features/panels/UrdfDebug/UrdfDebugPanel.tsx similarity index 100% rename from src/features/panels/UrdfDebug/Component.tsx rename to src/features/panels/UrdfDebug/UrdfDebugPanel.tsx diff --git a/src/features/panels/UrdfDebug/definition.tsx b/src/features/panels/UrdfDebug/definition.tsx index 313628e..8e3c505 100644 --- a/src/features/panels/UrdfDebug/definition.tsx +++ b/src/features/panels/UrdfDebug/definition.tsx @@ -13,7 +13,7 @@ import { defaultUrdfDebugConfig, type UrdfDebugConfig } from './defaults'; import { parseUrdfDebugConfig } from './schema'; const UrdfDebugPanel = lazy(async () => { - const m = await import('./Component'); + const m = await import('./UrdfDebugPanel'); return { default: m.UrdfDebugPanel }; }); From ffe5adbd6e3aa9699f3636c6b61cbaabda9be1f2 Mon Sep 17 00:00:00 2001 From: joaner Date: Wed, 27 May 2026 15:41:30 +0800 Subject: [PATCH 03/13] refactor(threeD): rename foxglove-core directory to core Shorten the shared 3D rendering module path and update imports across ThreeD, Pose, UrdfDebug, and urdf-preview entrypoint. --- src/entrypoints/urdf-preview.ts | 2 +- src/features/panels/Pose/PosePanel.tsx | 2 +- src/features/panels/ThreeD/ThreeDPanel.tsx | 4 ++-- .../panels/ThreeD/{foxglove-core => core}/meshFormat.test.ts | 0 .../panels/ThreeD/{foxglove-core => core}/meshFormat.ts | 0 .../panels/ThreeD/{foxglove-core => core}/renderables.test.ts | 0 .../panels/ThreeD/{foxglove-core => core}/renderables.ts | 0 .../panels/ThreeD/{foxglove-core => core}/transformTree.ts | 0 src/features/panels/ThreeD/{foxglove-core => core}/types.ts | 0 src/features/panels/ThreeD/{foxglove-core => core}/urdf.ts | 0 src/features/panels/UrdfDebug/Preview.tsx | 4 ++-- src/features/panels/UrdfDebug/jointPose.ts | 2 +- src/features/panels/UrdfDebug/urdfAnalysis.ts | 4 ++-- 13 files changed, 9 insertions(+), 9 deletions(-) rename src/features/panels/ThreeD/{foxglove-core => core}/meshFormat.test.ts (100%) rename src/features/panels/ThreeD/{foxglove-core => core}/meshFormat.ts (100%) rename src/features/panels/ThreeD/{foxglove-core => core}/renderables.test.ts (100%) rename src/features/panels/ThreeD/{foxglove-core => core}/renderables.ts (100%) rename src/features/panels/ThreeD/{foxglove-core => core}/transformTree.ts (100%) rename src/features/panels/ThreeD/{foxglove-core => core}/types.ts (100%) rename src/features/panels/ThreeD/{foxglove-core => core}/urdf.ts (100%) 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/panels/Pose/PosePanel.tsx b/src/features/panels/Pose/PosePanel.tsx index 961273a..ee04144 100644 --- a/src/features/panels/Pose/PosePanel.tsx +++ b/src/features/panels/Pose/PosePanel.tsx @@ -27,7 +27,7 @@ import * as THREE from 'three'; import { Line2 } from 'three/examples/jsm/lines/Line2.js'; import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; -import { TransformTree } from '../ThreeD/foxglove-core/transformTree'; +import { TransformTree } from '../ThreeD/core/transformTree'; import type { PoseConfig } from './defaults'; import { buildTrajectoryLineBands, type TrajectoryLineBand } from './trajectory'; diff --git a/src/features/panels/ThreeD/ThreeDPanel.tsx b/src/features/panels/ThreeD/ThreeDPanel.tsx index 3f05e1c..5c4c14e 100644 --- a/src/features/panels/ThreeD/ThreeDPanel.tsx +++ b/src/features/panels/ThreeD/ThreeDPanel.tsx @@ -36,8 +36,8 @@ import { disposeRobotRenderable, type MeshLoadProgress, type RobotRenderable, -} from './foxglove-core/renderables'; -import type { JointStateMsg, TFMessage } from './foxglove-core/types'; +} from './core/renderables'; +import type { JointStateMsg, TFMessage } from './core/types'; import { defaultThreeDConfig, diff --git a/src/features/panels/ThreeD/foxglove-core/meshFormat.test.ts b/src/features/panels/ThreeD/core/meshFormat.test.ts similarity index 100% rename from src/features/panels/ThreeD/foxglove-core/meshFormat.test.ts rename to src/features/panels/ThreeD/core/meshFormat.test.ts diff --git a/src/features/panels/ThreeD/foxglove-core/meshFormat.ts b/src/features/panels/ThreeD/core/meshFormat.ts similarity index 100% rename from src/features/panels/ThreeD/foxglove-core/meshFormat.ts rename to src/features/panels/ThreeD/core/meshFormat.ts diff --git a/src/features/panels/ThreeD/foxglove-core/renderables.test.ts b/src/features/panels/ThreeD/core/renderables.test.ts similarity index 100% rename from src/features/panels/ThreeD/foxglove-core/renderables.test.ts rename to src/features/panels/ThreeD/core/renderables.test.ts diff --git a/src/features/panels/ThreeD/foxglove-core/renderables.ts b/src/features/panels/ThreeD/core/renderables.ts similarity index 100% rename from src/features/panels/ThreeD/foxglove-core/renderables.ts rename to src/features/panels/ThreeD/core/renderables.ts diff --git a/src/features/panels/ThreeD/foxglove-core/transformTree.ts b/src/features/panels/ThreeD/core/transformTree.ts similarity index 100% rename from src/features/panels/ThreeD/foxglove-core/transformTree.ts rename to src/features/panels/ThreeD/core/transformTree.ts diff --git a/src/features/panels/ThreeD/foxglove-core/types.ts b/src/features/panels/ThreeD/core/types.ts similarity index 100% rename from src/features/panels/ThreeD/foxglove-core/types.ts rename to src/features/panels/ThreeD/core/types.ts diff --git a/src/features/panels/ThreeD/foxglove-core/urdf.ts b/src/features/panels/ThreeD/core/urdf.ts similarity index 100% rename from src/features/panels/ThreeD/foxglove-core/urdf.ts rename to src/features/panels/ThreeD/core/urdf.ts diff --git a/src/features/panels/UrdfDebug/Preview.tsx b/src/features/panels/UrdfDebug/Preview.tsx index 8cb4147..be80f67 100644 --- a/src/features/panels/UrdfDebug/Preview.tsx +++ b/src/features/panels/UrdfDebug/Preview.tsx @@ -13,8 +13,8 @@ import { type MeshLoadProgress, type MeshUpAxis, type RobotRenderable, -} from '../ThreeD/foxglove-core/renderables'; -import type { JointStateMsg } from '../ThreeD/foxglove-core/types'; +} from '../ThreeD/core/renderables'; +import type { JointStateMsg } from '../ThreeD/core/types'; import { countVisibleFrameObjects, type UrdfPreviewBuildResult, diff --git a/src/features/panels/UrdfDebug/jointPose.ts b/src/features/panels/UrdfDebug/jointPose.ts index 862f743..9ce712d 100644 --- a/src/features/panels/UrdfDebug/jointPose.ts +++ b/src/features/panels/UrdfDebug/jointPose.ts @@ -1,4 +1,4 @@ -import type { JointStateMsg } from '../ThreeD/foxglove-core/types'; +import type { JointStateMsg } from '../ThreeD/core/types'; import type { JointStateLike } from './jointStateMapping'; import type { UrdfJointDescriptor, UrdfMimicJoint } from './urdfAnalysis'; diff --git a/src/features/panels/UrdfDebug/urdfAnalysis.ts b/src/features/panels/UrdfDebug/urdfAnalysis.ts index 3e22b37..f480e41 100644 --- a/src/features/panels/UrdfDebug/urdfAnalysis.ts +++ b/src/features/panels/UrdfDebug/urdfAnalysis.ts @@ -1,5 +1,5 @@ -import { parseUrdf } from '../ThreeD/foxglove-core/urdf'; -import type { UrdfJoint } from '../ThreeD/foxglove-core/types'; +import { parseUrdf } from '../ThreeD/core/urdf'; +import type { UrdfJoint } from '../ThreeD/core/types'; import { applyUrdfVisualCorrection } from './urdfVisualCorrection'; export { TELEOP_ROTATE_MESH_RPY, applyUrdfVisualCorrection } from './urdfVisualCorrection'; From c17becd0e446b5fc1e0fb18beb789d6b32b6cbe0 Mon Sep 17 00:00:00 2001 From: joaner Date: Wed, 27 May 2026 15:44:46 +0800 Subject: [PATCH 04/13] refactor(panels): unify panel-specific modules under core/ directories Move align-core, image-core, and audio-core into Align/core, Image/core, and Audio/core to match the ThreeD panel layout and simplify import paths. --- docs/ARCHITECTURE.zh.md | 2 +- .../layout/autoLayout/applyDefaultRosDockLayout.ts | 2 +- src/features/panels/Align/AlignPanel.tsx | 2 +- src/features/panels/Align/AlignPanelSettings.tsx | 2 +- .../core}/alignTimeUtils.test.ts | 0 .../{align-core => Align/core}/alignTimeUtils.ts | 0 src/features/panels/Align/defaults.ts | 2 +- src/features/panels/Align/schema.ts | 2 +- src/features/panels/Audio/AudioPanel.tsx | 10 +++++----- .../{audio-core => core}/audioPlaybackController.ts | 0 .../Audio/{audio-core => core}/normalize.test.ts | 0 .../panels/Audio/{audio-core => core}/normalize.ts | 0 .../Audio/{audio-core => core}/parseAudioInfo.ts | 0 .../panels/Audio/{audio-core => core}/pcmConvert.ts | 0 .../Audio/{audio-core => core}/resolveAudioInfo.ts | 0 .../panels/Audio/{audio-core => core}/types.ts | 0 .../Audio/{audio-core => core}/waveformBuffer.ts | 0 src/features/panels/Image/ImagePanel.tsx | 12 ++++++------ src/features/panels/Image/ImagePanelSettings.tsx | 4 ++-- .../Image/{image-core => core}/ImageRender.worker.ts | 0 .../Image/{image-core => core}/asyncTimeout.test.ts | 0 .../Image/{image-core => core}/asyncTimeout.ts | 0 .../panels/Image/{image-core => core}/h264.test.ts | 0 .../panels/Image/{image-core => core}/h264.ts | 0 .../{image-core => core}/h264SeekRepair.test.ts | 0 .../Image/{image-core => core}/h264SeekRepair.ts | 0 .../Image/{image-core => core}/imageColorMode.ts | 0 .../Image/{image-core => core}/imageTypes.test.ts | 0 .../panels/Image/{image-core => core}/imageTypes.ts | 0 .../{image-core => core}/imageWorkerProtocol.ts | 0 .../{image-core => core}/messageFrameAdapter.test.ts | 0 .../{image-core => core}/messageFrameAdapter.ts | 0 .../Image/{image-core => core}/rawDecoders.test.ts | 0 .../panels/Image/{image-core => core}/rawDecoders.ts | 0 src/features/panels/Image/defaults.ts | 2 +- src/features/panels/Image/schema.ts | 2 +- 36 files changed, 21 insertions(+), 21 deletions(-) rename src/features/panels/{align-core => Align/core}/alignTimeUtils.test.ts (100%) rename src/features/panels/{align-core => Align/core}/alignTimeUtils.ts (100%) rename src/features/panels/Audio/{audio-core => core}/audioPlaybackController.ts (100%) rename src/features/panels/Audio/{audio-core => core}/normalize.test.ts (100%) rename src/features/panels/Audio/{audio-core => core}/normalize.ts (100%) rename src/features/panels/Audio/{audio-core => core}/parseAudioInfo.ts (100%) rename src/features/panels/Audio/{audio-core => core}/pcmConvert.ts (100%) rename src/features/panels/Audio/{audio-core => core}/resolveAudioInfo.ts (100%) rename src/features/panels/Audio/{audio-core => core}/types.ts (100%) rename src/features/panels/Audio/{audio-core => core}/waveformBuffer.ts (100%) rename src/features/panels/Image/{image-core => core}/ImageRender.worker.ts (100%) rename src/features/panels/Image/{image-core => core}/asyncTimeout.test.ts (100%) rename src/features/panels/Image/{image-core => core}/asyncTimeout.ts (100%) rename src/features/panels/Image/{image-core => core}/h264.test.ts (100%) rename src/features/panels/Image/{image-core => core}/h264.ts (100%) rename src/features/panels/Image/{image-core => core}/h264SeekRepair.test.ts (100%) rename src/features/panels/Image/{image-core => core}/h264SeekRepair.ts (100%) rename src/features/panels/Image/{image-core => core}/imageColorMode.ts (100%) rename src/features/panels/Image/{image-core => core}/imageTypes.test.ts (100%) rename src/features/panels/Image/{image-core => core}/imageTypes.ts (100%) rename src/features/panels/Image/{image-core => core}/imageWorkerProtocol.ts (100%) rename src/features/panels/Image/{image-core => core}/messageFrameAdapter.test.ts (100%) rename src/features/panels/Image/{image-core => core}/messageFrameAdapter.ts (100%) rename src/features/panels/Image/{image-core => core}/rawDecoders.test.ts (100%) rename src/features/panels/Image/{image-core => core}/rawDecoders.ts (100%) diff --git a/docs/ARCHITECTURE.zh.md b/docs/ARCHITECTURE.zh.md index 76fe1f4..3b1441b 100644 --- a/docs/ARCHITECTURE.zh.md +++ b/docs/ARCHITECTURE.zh.md @@ -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/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/panels/Align/AlignPanel.tsx b/src/features/panels/Align/AlignPanel.tsx index d3396ee..27868dc 100644 --- a/src/features/panels/Align/AlignPanel.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/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/AudioPanel.tsx b/src/features/panels/Audio/AudioPanel.tsx index e35c75d..94e57da 100644 --- a/src/features/panels/Audio/AudioPanel.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/Image/ImagePanel.tsx b/src/features/panels/Image/ImagePanel.tsx index 8d8a242..b0be675 100644 --- a/src/features/panels/Image/ImagePanel.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/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'; From 6d3dcf5c1449a3f0f63c36638dcdb2b14baf9d7d Mon Sep 17 00:00:00 2001 From: joaner <1726541+joaner@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:16:52 +0800 Subject: [PATCH 05/13] feat(plot): generic Plot panel with JointState support and legacy deprecation (#1) * feat(plot): implement Plot panel with dataset handling and configuration Add a new Plot panel feature, including components for rendering plots, managing datasets, and configuring series. Introduce utilities for reading message ranges and extracting plot path values. Implement settings for customizing plot behavior, such as axis modes and series configurations. Ensure integration with existing message pipeline for real-time data visualization. * feat(plot): add MCAP topic inspection script and enhance plot dataset handling Introduce a new script for inspecting MCAP topics to facilitate Plot panel fixture generation. Enhance the plot dataset handling by implementing efficient random access by topic and time, improving the overall performance and usability of the Plot panel. Update related types and configurations to support these features, ensuring better integration with existing data visualization workflows. * feat(plot): enhance dataset handling and introduce joint state path utilities Refactor the dataset management in the Plot panel to improve performance and usability. Introduce new utilities for handling joint state paths, including functions for combining and stripping paths. Update the message path extraction logic to support multiple comma-separated and whitespace-separated paths. Enhance the plot configuration actions and selectors for better integration with the new dataset features, ensuring a more efficient data visualization workflow. * feat(plot): deprecate JointStatePlot and enhance panel definitions Introduce warnings for deprecated JointStatePlot usage in the layout and runtime components, advising migration to the Plot panel. Update panel definitions to exclude JointStatePlot from addable options and implement new utilities for handling joint state paths. Enhance plot configuration normalization to support legacy series merging and improve dataset handling for better visualization workflows. * fix(plot): improve error handling and code clarity in dataset tests and plot alignment Enhance dataset tests by adding error checks for undefined indices, ensuring robust validation of non-null values. Refactor plot alignment logic to handle potential undefined entries gracefully. Additionally, streamline the extraction of y-values in dataset processing for better readability and maintainability. Remove unused type imports to clean up the codebase. --------- Co-authored-by: joaner --- scripts/inspect-mcap-topics.mjs | 53 +++ src/core/analysis/timeSeries.ts | 44 +++ src/core/players/IterablePlayer.ts | 1 + src/core/preferences/foxgloveLayout.ts | 10 + src/core/types/player.ts | 2 + src/core/types/ros.ts | 6 + src/features/layout/PanelTabHeader.tsx | 4 +- src/features/layout/WelcomePanelContent.tsx | 5 +- .../panels/JointStatePlot/definition.tsx | 1 + .../panels/Plot/PlotLegendSettings.tsx | 126 +++++++ src/features/panels/Plot/PlotPanel.tsx | 270 ++++++++++++++ .../panels/Plot/PlotPanelSettings.tsx | 340 +++++++++++++++++ src/features/panels/Plot/adapters/index.ts | 182 +++++++++ src/features/panels/Plot/autoDetect.test.ts | 53 +++ src/features/panels/Plot/autoDetect.ts | 61 +++ src/features/panels/Plot/datasets.test.ts | 328 ++++++++++++++++ src/features/panels/Plot/datasets.ts | 55 +++ src/features/panels/Plot/defaults.ts | 99 +++++ src/features/panels/Plot/definition.tsx | 123 ++++++ src/features/panels/Plot/exportCsv.ts | 68 ++++ .../fixtures/gripperPickAndPlace.fixture.ts | 10 + src/features/panels/Plot/index.ts | 3 + src/features/panels/Plot/jointStatePaths.ts | 22 ++ src/features/panels/Plot/messagePath.test.ts | 66 ++++ src/features/panels/Plot/messagePath.ts | 248 +++++++++++++ .../panels/Plot/pickDefaultPlotTopic.ts | 16 + src/features/panels/Plot/plotAlign.ts | 119 ++++++ src/features/panels/Plot/plotChart.test.ts | 63 ++++ src/features/panels/Plot/plotChart.ts | 350 ++++++++++++++++++ src/features/panels/Plot/plotConfigActions.ts | 88 +++++ .../panels/Plot/plotConfigNormalize.test.ts | 67 ++++ .../panels/Plot/plotConfigNormalize.ts | 108 ++++++ .../panels/Plot/plotConfigSelectors.ts | 79 ++++ .../Plot/plotDatasetPerformance.test.ts | 123 ++++++ src/features/panels/Plot/plotEventIndex.ts | 24 ++ .../panels/Plot/plotLegendVisibility.test.ts | 38 ++ .../panels/Plot/plotLegendVisibility.ts | 51 +++ .../panels/Plot/plotPanelRuntimeStore.test.ts | 30 ++ .../panels/Plot/plotPanelRuntimeStore.ts | 80 ++++ .../panels/Plot/plotPointCollector.ts | 198 ++++++++++ .../panels/Plot/plotSchemaRegistry.test.ts | 112 ++++++ src/features/panels/Plot/plotTopicService.ts | 119 ++++++ src/features/panels/Plot/plotWarnings.test.ts | 39 ++ src/features/panels/Plot/plotWarnings.ts | 48 +++ src/features/panels/Plot/plottableSchemas.ts | 35 ++ src/features/panels/Plot/rangeReader.ts | 95 +++++ src/features/panels/Plot/schema.test.ts | 36 ++ src/features/panels/Plot/schema.ts | 102 +++++ .../Plot/schemaRegistry/plotSchemaRegistry.ts | 83 +++++ .../panels/Plot/schemaRegistry/types.ts | 41 ++ src/features/panels/Plot/topicPaths.test.ts | 73 ++++ src/features/panels/Plot/topicPaths.ts | 69 ++++ src/features/panels/Plot/types.ts | 47 +++ src/features/panels/Plot/usePlotChart.ts | 167 +++++++++ src/features/panels/Plot/usePlotPanelData.ts | 150 ++++++++ .../panels/Plot/usePlotTopicDetection.ts | 71 ++++ .../panels/framework/PanelRuntimeShell.tsx | 8 + src/features/panels/framework/panelIcons.tsx | 1 + .../panels/framework/panelMessageSlug.ts | 1 + src/features/panels/framework/types.ts | 3 + src/features/panels/registry/index.test.ts | 14 + src/features/panels/registry/index.ts | 16 +- src/infra/sources/BagIterableSource.ts | 1 + .../sources/McapIndexedIterableSource.ts | 1 + src/infra/sources/RosDb3IterableSource.ts | 1 + src/infra/sources/bvh/BvhIterableSource.ts | 1 + src/infra/sources/hdf5/Hdf5IterableSource.ts | 1 + src/shared/intl/messages/en/layout.json | 1 + src/shared/intl/messages/en/panels.json | 61 ++- src/shared/intl/messages/ja/layout.json | 1 + src/shared/intl/messages/ja/panels.json | 61 ++- src/shared/intl/messages/zh/layout.json | 1 + src/shared/intl/messages/zh/panels.json | 61 ++- 73 files changed, 5117 insertions(+), 18 deletions(-) create mode 100644 scripts/inspect-mcap-topics.mjs create mode 100644 src/features/panels/Plot/PlotLegendSettings.tsx create mode 100644 src/features/panels/Plot/PlotPanel.tsx create mode 100644 src/features/panels/Plot/PlotPanelSettings.tsx create mode 100644 src/features/panels/Plot/adapters/index.ts create mode 100644 src/features/panels/Plot/autoDetect.test.ts create mode 100644 src/features/panels/Plot/autoDetect.ts create mode 100644 src/features/panels/Plot/datasets.test.ts create mode 100644 src/features/panels/Plot/datasets.ts create mode 100644 src/features/panels/Plot/defaults.ts create mode 100644 src/features/panels/Plot/definition.tsx create mode 100644 src/features/panels/Plot/exportCsv.ts create mode 100644 src/features/panels/Plot/fixtures/gripperPickAndPlace.fixture.ts create mode 100644 src/features/panels/Plot/index.ts create mode 100644 src/features/panels/Plot/jointStatePaths.ts create mode 100644 src/features/panels/Plot/messagePath.test.ts create mode 100644 src/features/panels/Plot/messagePath.ts create mode 100644 src/features/panels/Plot/pickDefaultPlotTopic.ts create mode 100644 src/features/panels/Plot/plotAlign.ts create mode 100644 src/features/panels/Plot/plotChart.test.ts create mode 100644 src/features/panels/Plot/plotChart.ts create mode 100644 src/features/panels/Plot/plotConfigActions.ts create mode 100644 src/features/panels/Plot/plotConfigNormalize.test.ts create mode 100644 src/features/panels/Plot/plotConfigNormalize.ts create mode 100644 src/features/panels/Plot/plotConfigSelectors.ts create mode 100644 src/features/panels/Plot/plotDatasetPerformance.test.ts create mode 100644 src/features/panels/Plot/plotEventIndex.ts create mode 100644 src/features/panels/Plot/plotLegendVisibility.test.ts create mode 100644 src/features/panels/Plot/plotLegendVisibility.ts create mode 100644 src/features/panels/Plot/plotPanelRuntimeStore.test.ts create mode 100644 src/features/panels/Plot/plotPanelRuntimeStore.ts create mode 100644 src/features/panels/Plot/plotPointCollector.ts create mode 100644 src/features/panels/Plot/plotSchemaRegistry.test.ts create mode 100644 src/features/panels/Plot/plotTopicService.ts create mode 100644 src/features/panels/Plot/plotWarnings.test.ts create mode 100644 src/features/panels/Plot/plotWarnings.ts create mode 100644 src/features/panels/Plot/plottableSchemas.ts create mode 100644 src/features/panels/Plot/rangeReader.ts create mode 100644 src/features/panels/Plot/schema.test.ts create mode 100644 src/features/panels/Plot/schema.ts create mode 100644 src/features/panels/Plot/schemaRegistry/plotSchemaRegistry.ts create mode 100644 src/features/panels/Plot/schemaRegistry/types.ts create mode 100644 src/features/panels/Plot/topicPaths.test.ts create mode 100644 src/features/panels/Plot/topicPaths.ts create mode 100644 src/features/panels/Plot/types.ts create mode 100644 src/features/panels/Plot/usePlotChart.ts create mode 100644 src/features/panels/Plot/usePlotPanelData.ts create mode 100644 src/features/panels/Plot/usePlotTopicDetection.ts create mode 100644 src/features/panels/registry/index.test.ts 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.ts b/src/core/players/IterablePlayer.ts index 26e42dd..08c0a26 100644 --- a/src/core/players/IterablePlayer.ts +++ b/src/core/players/IterablePlayer.ts @@ -411,6 +411,7 @@ export class IterablePlayer implements Player { isLooping: this._isLooping, speed: this._speed, problems: this._initialization.problems, + randomAccessByTopic: this._initialization.randomAccessByTopic, }; messageBus.reset(); 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..8d638ab 100644 --- a/src/core/types/player.ts +++ b/src/core/types/player.ts @@ -73,6 +73,8 @@ export interface PlayerState { isLooping: boolean; speed: number; problems: PlayerProblem[]; + /** Whether the source supports efficient per-topic random access reads. */ + randomAccessByTopic?: boolean; }; } 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/features/layout/PanelTabHeader.tsx b/src/features/layout/PanelTabHeader.tsx index 0ca1349..2041f18 100644 --- a/src/features/layout/PanelTabHeader.tsx +++ b/src/features/layout/PanelTabHeader.tsx @@ -13,7 +13,7 @@ import { } from './rosviewTabContextMenu'; import { WELCOME_PANEL_ID } from './dockviewIds'; import { openDockviewPanel } from './dockviewController'; -import { getFoxgloveAdapter, getPanelDefinition, getPanelDefinitions, hasFoxgloveAdapter, hasPanelDefinition } from '../panels/registry'; +import { getAddablePanelDefinitions, getFoxgloveAdapter, getPanelDefinition, hasFoxgloveAdapter, hasPanelDefinition } from '../panels/registry'; import { PanelTabAddPanelDefinitionsSubmenus } from './PanelTabAddPanelDefinitionsSubmenus'; import { PanelTabActions } from './PanelTabActions'; import { PANEL_TAB_EXPANDED_MIN_WIDTH_PX } from './layoutConstants'; @@ -70,7 +70,7 @@ export const PanelTabHeader: React.FC = ({ 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/panels/JointStatePlot/definition.tsx b/src/features/panels/JointStatePlot/definition.tsx index e442409..ac9c8df 100644 --- a/src/features/panels/JointStatePlot/definition.tsx +++ b/src/features/panels/JointStatePlot/definition.tsx @@ -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..533a579 --- /dev/null +++ b/src/features/panels/Plot/PlotPanel.tsx @@ -0,0 +1,270 @@ +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, + 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(() => 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, + }); + + 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 && hasPlotPaths; + 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)); + } + }; + + return ( +
+
+
+ +
+ {showJointStateFields && ( + + )} + {status && ( + {status} + )} +
+
+
+ {!hasSeries && ( +
+ {formatMessage({ id: 'panels.plot.empty.selectTopic' })} +
+ )} + {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..7c18e7e --- /dev/null +++ b/src/features/panels/Plot/PlotPanelSettings.tsx @@ -0,0 +1,340 @@ +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 { 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( + () => [ + { 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' }) }, + { value: 'custom' as const, label: formatMessage({ id: 'panels.plot.settings.enum.xAxis.custom' }) }, + { value: 'currentCustom' as const, label: formatMessage({ id: 'panels.plot.settings.enum.xAxis.currentCustom' }) }, + ], + [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..936a6b3 --- /dev/null +++ b/src/features/panels/Plot/datasets.test.ts @@ -0,0 +1,328 @@ +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('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..688f5e8 --- /dev/null +++ b/src/features/panels/Plot/messagePath.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { extractPlotPathValues } 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 }, + ]); + }); +}); diff --git a/src/features/panels/Plot/messagePath.ts b/src/features/panels/Plot/messagePath.ts new file mode 100644 index 0000000..239bf99 --- /dev/null +++ b/src/features/panels/Plot/messagePath.ts @@ -0,0 +1,248 @@ +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; +} + +function parseSelector(raw: string | undefined): Selector { + if (raw == null) return { kind: 'none' }; + const selector = raw.trim(); + if (selector === '' || selector === ':') return { kind: 'slice' }; + if (selector.includes(':')) { + const [startRaw, endRaw] = selector.split(':', 2); + const start = startRaw ? Number(startRaw) : undefined; + const end = endRaw ? Number(endRaw) : undefined; + return { + kind: 'slice', + start: Number.isFinite(start) ? start : undefined, + end: Number.isFinite(end) ? end : undefined, + }; + } + 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; +} + +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 start = Math.max(0, selector.start ?? 0); + const end = Math.min(value.length, selector.end ?? value.length); + const names = readNames(message); + const out: Array<{ key: string; label: string; value: unknown }> = []; + for (let i = start; i < end; 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