From 4a8b5fbc58f8c6982689b8c4d7f78ef1812014b4 Mon Sep 17 00:00:00 2001 From: puxiaokang Date: Sat, 6 Jun 2026 17:35:58 +0800 Subject: [PATCH] feat(plot): improve topic auto-detect, TF support, and chart UX. Make Plot easier to use for multi-type ROS data by fixing path detection, supporting TFMessage, and adding zoom, reset, and inline legend controls. --- .../panels/Plot/PlotChartLegend.test.ts | 33 +++ src/features/panels/Plot/PlotChartLegend.tsx | 232 ++++++++++++++++++ .../panels/Plot/PlotLegendSettings.tsx | 3 +- src/features/panels/Plot/PlotPanel.tsx | 42 +++- .../panels/Plot/PlotPanelSettings.tsx | 191 +++++++++++++- src/features/panels/Plot/adapters/index.ts | 31 +++ src/features/panels/Plot/autoDetect.test.ts | 47 ++++ src/features/panels/Plot/autoDetect.ts | 19 +- src/features/panels/Plot/datasets.test.ts | 65 +++++ .../panels/Plot/fieldDiscovery.test.ts | 47 ++++ src/features/panels/Plot/fieldDiscovery.ts | 171 +++++++++++++ src/features/panels/Plot/messagePath.test.ts | 34 +++ src/features/panels/Plot/messagePath.ts | 18 +- src/features/panels/Plot/plotChart.test.ts | 44 ++++ src/features/panels/Plot/plotChart.ts | 72 +++++- .../panels/Plot/plotLegendVisibility.test.ts | 14 ++ .../panels/Plot/plotLegendVisibility.ts | 21 ++ .../panels/Plot/plotSchemaRegistry.test.ts | 12 + .../panels/Plot/plotTopicService.test.ts | 79 ++++++ src/features/panels/Plot/plotTopicService.ts | 62 ++++- .../panels/Plot/plottableSchemas.test.ts | 12 + src/features/panels/Plot/plottableSchemas.ts | 10 +- .../Plot/schemaRegistry/plotSchemaRegistry.ts | 2 + .../panels/Plot/schemaRegistry/types.ts | 5 +- src/features/panels/Plot/usePlotChart.test.ts | 17 ++ src/features/panels/Plot/usePlotChart.ts | 197 ++++++++++++++- .../panels/Plot/usePlotTopicDetection.ts | 8 +- src/shared/intl/messages/en/panels.json | 17 ++ src/shared/intl/messages/ja/panels.json | 17 ++ src/shared/intl/messages/zh/panels.json | 17 ++ 30 files changed, 1509 insertions(+), 30 deletions(-) create mode 100644 src/features/panels/Plot/PlotChartLegend.test.ts create mode 100644 src/features/panels/Plot/PlotChartLegend.tsx create mode 100644 src/features/panels/Plot/fieldDiscovery.test.ts create mode 100644 src/features/panels/Plot/fieldDiscovery.ts create mode 100644 src/features/panels/Plot/plotTopicService.test.ts create mode 100644 src/features/panels/Plot/plottableSchemas.test.ts diff --git a/src/features/panels/Plot/PlotChartLegend.test.ts b/src/features/panels/Plot/PlotChartLegend.test.ts new file mode 100644 index 0000000..5842d6e --- /dev/null +++ b/src/features/panels/Plot/PlotChartLegend.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { collapsedPlotLegendEntries, filterPlotLegendEntries } from './PlotChartLegend'; + +const entries = [ + { key: 's1:a', label: '/joint_states · shoulder', color: '#111111' }, + { key: 's1:b', label: '/joint_states · elbow', color: '#222222' }, + { key: 's2:x', label: '/tf · base_link', color: '#333333' }, +]; + +describe('filterPlotLegendEntries', () => { + it('returns all entries for an empty query', () => { + expect(filterPlotLegendEntries(entries, '')).toEqual(entries); + }); + + it('filters labels case-insensitively', () => { + expect(filterPlotLegendEntries(entries, 'TF').map((entry) => entry.key)).toEqual(['s2:x']); + expect(filterPlotLegendEntries(entries, 'joint').map((entry) => entry.key)).toEqual(['s1:a', 's1:b']); + }); +}); + +describe('collapsedPlotLegendEntries', () => { + it('prioritizes visible entries so Only remains visible when collapsed', () => { + expect(collapsedPlotLegendEntries(entries, ['s1:a', 's1:b']).map((entry) => entry.key)).toEqual([ + 's2:x', + ]); + }); + + it('falls back to original order when everything is hidden', () => { + expect(collapsedPlotLegendEntries(entries, entries.map((entry) => entry.key)).map((entry) => entry.key)).toEqual([ + 's1:a', + ]); + }); +}); diff --git a/src/features/panels/Plot/PlotChartLegend.tsx b/src/features/panels/Plot/PlotChartLegend.tsx new file mode 100644 index 0000000..88c5382 --- /dev/null +++ b/src/features/panels/Plot/PlotChartLegend.tsx @@ -0,0 +1,232 @@ +import React, { useMemo, useState } from 'react'; +import { ChevronDown, ChevronUp, Eye, EyeOff } from 'lucide-react'; +import { useIntl } from 'react-intl'; +import type { PlotConfig } from './defaults'; +import { + isPlotLegendVisible, + setOnlyPlotLegendVisible, + setPlotLegendGroupVisible, + setPlotLegendVisible, + visiblePlotLegendCount, +} from './plotLegendVisibility'; +import { usePlotLegendEntries, type PlotLegendEntry } from './plotPanelRuntimeStore'; + +const COLLAPSED_LIMIT = 1; + +interface PlotChartLegendProps { + panelId: string; + config: PlotConfig; + setConfig: (next: PlotConfig | ((prev: PlotConfig) => PlotConfig)) => void; +} + +function stopPanelInteraction(event: React.SyntheticEvent) { + event.stopPropagation(); +} + +export function filterPlotLegendEntries( + entries: readonly PlotLegendEntry[], + query: string, +): PlotLegendEntry[] { + const needle = query.trim().toLowerCase(); + if (!needle) return [...entries]; + return entries.filter((entry) => entry.label.toLowerCase().includes(needle)); +} + +export function collapsedPlotLegendEntries( + entries: readonly PlotLegendEntry[], + hiddenKeys: readonly string[], + limit = COLLAPSED_LIMIT, +): PlotLegendEntry[] { + const visible = entries.filter((entry) => isPlotLegendVisible(hiddenKeys, entry.key)); + const hidden = entries.filter((entry) => !isPlotLegendVisible(hiddenKeys, entry.key)); + return [...visible, ...hidden].slice(0, limit); +} + +function LegendRow({ + entry, + visible, + onToggle, + onOnly, + showOnlyAction, +}: { + entry: PlotLegendEntry; + visible: boolean; + onToggle: () => void; + onOnly?: () => void; + showOnlyAction?: boolean; +}): React.ReactNode { + const { formatMessage } = useIntl(); + return ( +
+ + + + {showOnlyAction && onOnly && ( + + )} + {!showOnlyAction && ( + + {formatMessage({ id: 'panels.plot.legend.only' })} + + )} +
+ ); +} + +export function PlotChartLegend({ + panelId, + config, + setConfig, +}: PlotChartLegendProps): React.ReactNode { + const { formatMessage } = useIntl(); + const entries = usePlotLegendEntries(panelId); + const [expanded, setExpanded] = useState(false); + const [query, setQuery] = useState(''); + + const filtered = useMemo(() => filterPlotLegendEntries(entries, query), [entries, query]); + const hiddenKeys = config.hiddenLegendKeys; + const collapsedEntries = useMemo( + () => collapsedPlotLegendEntries(entries, hiddenKeys), + [entries, hiddenKeys], + ); + const allKeys = useMemo(() => entries.map((entry) => entry.key), [entries]); + const visibleCount = visiblePlotLegendCount(entries, hiddenKeys); + + if (entries.length <= 1) return null; + + const setHiddenKeys = (next: string[]) => { + setConfig((prev) => ({ ...prev, hiddenLegendKeys: next })); + }; + + const toggleEntry = (entry: PlotLegendEntry) => { + setHiddenKeys(setPlotLegendVisible(hiddenKeys, entry.key, !isPlotLegendVisible(hiddenKeys, entry.key))); + }; + + const showOnly = (entry: PlotLegendEntry) => { + setHiddenKeys(setOnlyPlotLegendVisible(hiddenKeys, allKeys, entry.key)); + }; + + return ( +
+
+ + {formatMessage( + { id: 'panels.plot.legend.visibleCount' }, + { visible: visibleCount, total: entries.length }, + )} + + +
+ + {expanded ? ( + <> +
+ setQuery(event.target.value)} + className="h-7 w-full rounded border border-border bg-background px-2 text-[11px] outline-none focus:border-primary" + placeholder={formatMessage({ id: 'panels.plot.legend.searchPlaceholder' })} + aria-label={formatMessage({ id: 'panels.plot.legend.searchPlaceholder' })} + /> +
+ + +
+
+
+ {filtered.length === 0 ? ( +
+ {formatMessage({ id: 'panels.plot.legend.noMatches' })} +
+ ) : ( + filtered.map((entry) => ( + toggleEntry(entry)} + onOnly={() => showOnly(entry)} + showOnlyAction + /> + )) + )} +
+ + ) : ( +
+ {collapsedEntries.map((entry) => ( + toggleEntry(entry)} + /> + ))} +
+ )} +
+ ); +} diff --git a/src/features/panels/Plot/PlotLegendSettings.tsx b/src/features/panels/Plot/PlotLegendSettings.tsx index 9ceeabc..3b48f6e 100644 --- a/src/features/panels/Plot/PlotLegendSettings.tsx +++ b/src/features/panels/Plot/PlotLegendSettings.tsx @@ -7,6 +7,7 @@ import { plotLegendSelectionState, setPlotLegendGroupVisible, setPlotLegendVisible, + visiblePlotLegendCount, } from './plotLegendVisibility'; import { usePlotLegendEntries } from './plotPanelRuntimeStore'; @@ -48,7 +49,7 @@ export function PlotLegendSettings({ ); const visibleCount = useMemo( - () => entries.filter((entry) => isPlotLegendVisible(hiddenKeys, entry.key)).length, + () => visiblePlotLegendCount(entries, hiddenKeys), [entries, hiddenKeys], ); diff --git a/src/features/panels/Plot/PlotPanel.tsx b/src/features/panels/Plot/PlotPanel.tsx index e90f710..10ab988 100644 --- a/src/features/panels/Plot/PlotPanel.tsx +++ b/src/features/panels/Plot/PlotPanel.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { RotateCcw } from 'lucide-react'; import { useIntl } from 'react-intl'; import { useShallow } from 'zustand/react/shallow'; import 'uplot/dist/uPlot.min.css'; @@ -25,8 +26,9 @@ import { clearPlotLegendEntries, setPlotLegendEntries, } from './plotPanelRuntimeStore'; +import { PlotChartLegend } from './PlotChartLegend'; import { formatPlotDatasetWarning } from './plotWarnings'; -import { usePlotChart } from './usePlotChart'; +import { hasManualPlotViewport, usePlotChart, type PlotViewportState } from './usePlotChart'; import { usePlotPanelData } from './usePlotPanelData'; import { usePlotTopicDetection } from './usePlotTopicDetection'; import { timeToSec } from '@/core/analysis/timeSeries'; @@ -78,6 +80,8 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s const { formatMessage } = useIntl(); const containerRef = useRef(null); const autoTopicAppliedRef = useRef(false); + const pointerDownRef = useRef<{ x: number; y: number } | null>(null); + const [viewportState, setViewportState] = useState({ x: false, y: false }); const { startTime, endTime, randomAccessByTopic, topics } = useMessagePipeline( useShallow((state: MessagePipelineState) => ({ @@ -132,7 +136,11 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s [config.hiddenLegendKeys, dataset.series], ); - const uplotRef = usePlotChart({ + const handleViewportStateChange = useCallback((state: PlotViewportState) => { + setViewportState((prev) => (prev.x === state.x && prev.y === state.y ? prev : state)); + }, []); + + const { chartRef: uplotRef, resetViewport } = usePlotChart({ containerRef, player, panelId, @@ -142,6 +150,7 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s xRange, logStart: startTime, loading, + onViewportStateChange: handleViewportStateChange, }); useEffect(() => { @@ -221,6 +230,13 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s : null; const handleChartClick = (event: React.MouseEvent) => { + if (event.detail > 1) return; + const pointerDown = pointerDownRef.current; + if (pointerDown) { + const dx = event.clientX - pointerDown.x; + const dy = event.clientY - pointerDown.y; + if (Math.hypot(dx, dy) > 4) return; + } const chart = uplotRef.current; if (!chart || config.xAxisMode !== 'timestamp') return; // posToVal expects a position relative to the plotting area (the `over` @@ -236,6 +252,7 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s const showLoadingOverlay = loading && dataset.pointCount === 0; const progressFraction = progress && progress.total > 0 ? Math.min(1, progress.completed / progress.total) : null; + const showResetZoom = hasManualPlotViewport(viewportState); return (
@@ -277,8 +294,27 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s
{ + pointerDownRef.current = { x: event.clientX, y: event.clientY }; + }} onClick={handleChartClick} /> + + {showResetZoom && ( + + )} {!hasSeries && (
{formatMessage({ id: 'panels.plot.empty.selectTopic' })} diff --git a/src/features/panels/Plot/PlotPanelSettings.tsx b/src/features/panels/Plot/PlotPanelSettings.tsx index a737930..abf8d80 100644 --- a/src/features/panels/Plot/PlotPanelSettings.tsx +++ b/src/features/panels/Plot/PlotPanelSettings.tsx @@ -1,9 +1,11 @@ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo, useState } 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 { Time, TopicInfo } from '@/core/types/ros'; +import { isJointStateSchema } from '@/shared/ros/rosMessageTypes'; import type { PanelSettingsContext } from '../framework/types'; import { SettingsField, @@ -22,10 +24,12 @@ import { type JointStateField, type PlotConfig, type PlotLineStyle, + type PlotSeriesConfig, type PlotXAxisMode, } from './defaults'; import { exportPlotCsvFromConfig } from './exportCsv'; -import { isArrayLikePlotPath } from './messagePath'; +import { isArrayLikePlotPath, splitPlotPathList } from './messagePath'; +import { detectPlotPaths } from './autoDetect'; import { filterPlottableTopics, isPlottableSchema } from './plottableSchemas'; import { addPlotSeriesToConfig, @@ -35,8 +39,180 @@ import { } from './plotConfigActions'; import { buildTopicByName } from './plotConfigSelectors'; import { PlotLegendSettings } from './PlotLegendSettings'; +import { sampleTopicMessage } from './plotTopicService'; import { usePlotTopicDetection } from './usePlotTopicDetection'; +interface FieldTreeNode { + label: string; + path?: string; + children: FieldTreeNode[]; +} + +function pathSegments(path: string): string[] { + return path + .split('.') + .map((segment) => segment.trim()) + .filter(Boolean); +} + +function buildFieldTree(paths: string[]): FieldTreeNode[] { + const root: FieldTreeNode = { label: '', children: [] }; + + for (const path of paths) { + let cursor = root; + const segments = pathSegments(path); + for (let i = 0; i < segments.length; i++) { + const label = segments[i] ?? ''; + let next = cursor.children.find((child) => child.label === label); + if (!next) { + next = { label, children: [] }; + cursor.children.push(next); + } + if (i === segments.length - 1) next.path = path; + cursor = next; + } + } + + const sortChildren = (node: FieldTreeNode) => { + node.children.sort((a, b) => a.label.localeCompare(b.label)); + node.children.forEach(sortChildren); + }; + sortChildren(root); + return root.children; +} + +function togglePath(path: string, currentPath: string): string { + const selected = new Set(splitPlotPathList(currentPath)); + if (selected.has(path)) { + selected.delete(path); + } else { + selected.add(path); + } + return Array.from(selected).join(','); +} + +function FieldTreeRows({ + nodes, + selected, + onToggle, + depth = 0, +}: { + nodes: FieldTreeNode[]; + selected: Set; + onToggle: (path: string) => void; + depth?: number; +}): React.ReactNode { + return nodes.map((node) => ( +
+
+ {node.path ? ( + + ) : ( + {node.label} + )} +
+ {node.children.length > 0 && ( + + )} +
+ )); +} + +function TopicFieldTree({ + player, + series, + topicByName, + startTime, + endTime, + jointStateFields, + onPathChange, +}: { + player: PanelSettingsContext['player']; + series: PlotSeriesConfig; + topicByName: ReadonlyMap; + startTime?: Time; + endTime?: Time; + jointStateFields: JointStateField[]; + onPathChange: (path: string) => void; +}): React.ReactNode { + const { formatMessage } = useIntl(); + const schemaName = series.topic ? topicByName.get(series.topic)?.type : undefined; + const [sample, setSample] = useState(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + setSample(undefined); + if (!series.topic || !startTime || !endTime) return; + + setLoading(true); + void sampleTopicMessage({ player, topic: series.topic, startTime, endTime }) + .then((message) => { + if (!cancelled) setSample(message); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [endTime, player, series.topic, startTime]); + + const fields = useMemo(() => { + if (!schemaName) return []; + return detectPlotPaths({ + schemaName, + sample, + jointStateFields: isJointStateSchema(schemaName) ? jointStateFields : undefined, + }); + }, [jointStateFields, sample, schemaName]); + + const selected = useMemo(() => new Set(splitPlotPathList(series.path)), [series.path]); + const tree = useMemo(() => buildFieldTree(fields.map((field) => field.path)), [fields]); + + if (!series.topic) return null; + + return ( + +
+ {loading && tree.length === 0 ? ( +
+ {formatMessage({ id: 'panels.plot.settings.field.topicFields.loading', defaultMessage: 'Detecting fields…' })} +
+ ) : tree.length === 0 ? ( +
+ {formatMessage({ id: 'panels.plot.settings.field.topicFields.empty', defaultMessage: 'No numeric fields detected.' })} +
+ ) : ( + onPathChange(togglePath(path, series.path))} + /> + )} +
+
+ ); +} + export function PlotPanelSettings({ config, setConfig, @@ -295,6 +471,17 @@ export function PlotPanelSettings({ placeholder="/topic" /> + + setConfig((prev) => updateSeriesInConfig(prev, series.id, { path })) + } + /> ).transforms; + if (!Array.isArray(transforms) || transforms.length === 0) return false; + + return transforms.some((item) => { + if (!item || typeof item !== 'object') return false; + const transform = (item as Record).transform; + if (!transform || typeof transform !== 'object') return false; + const translation = (transform as Record).translation; + if (!translation || typeof translation !== 'object') return false; + return ['x', 'y', 'z'].some((axis) => { + const value = (translation as Record)[axis]; + return typeof value === 'number' && Number.isFinite(value); + }); + }); + }, +}; + export function validateDetectedPaths(sample: unknown, paths: DetectedPlotPath[]): DetectedPlotPath[] { if (!sample) return paths; return paths.filter((entry) => { diff --git a/src/features/panels/Plot/autoDetect.test.ts b/src/features/panels/Plot/autoDetect.test.ts index e286f55..ef6573e 100644 --- a/src/features/panels/Plot/autoDetect.test.ts +++ b/src/features/panels/Plot/autoDetect.test.ts @@ -37,6 +37,53 @@ describe('detectPlotPaths', () => { expect(paths[0]?.path).toBe('twist.linear.x'); }); + it('detects PoseStamped position paths', () => { + const paths = detectPlotPaths({ schemaName: 'geometry_msgs/msg/PoseStamped' }); + expect(paths.map((entry) => entry.path)).toEqual([ + 'pose.position.x', + 'pose.position.y', + 'pose.position.z', + ]); + }); + + it('falls back to sample discovery for unknown schemas', () => { + const paths = detectPlotPaths({ + schemaName: 'custom_msgs/msg/Foo', + sample: { foo: { bar: { x: 1, y: 2 } } }, + }); + expect(paths.map((entry) => entry.path)).toEqual(['foo.bar.x', 'foo.bar.y']); + }); + + it('detects TFMessage translation paths and exposes rotation for manual selection', () => { + const paths = detectPlotPaths({ + schemaName: 'tf2_msgs/msg/TFMessage', + sample: { + transforms: [{ + child_frame_id: 'link_Hips_R', + transform: { + translation: { x: 0, y: 0, z: 0.9712 }, + rotation: { x: 0.03, y: 0, z: 0, w: 0.99 }, + }, + }], + }, + }); + expect(paths.map((entry) => entry.path)).toEqual([ + 'transforms[:].transform.translation.x', + 'transforms[:].transform.translation.y', + 'transforms[:].transform.translation.z', + 'transforms[:].transform.rotation.x', + 'transforms[:].transform.rotation.y', + 'transforms[:].transform.rotation.z', + 'transforms[:].transform.rotation.w', + ]); + expect(paths.filter((entry) => entry.default === false).map((entry) => entry.path)).toEqual([ + 'transforms[:].transform.rotation.x', + 'transforms[:].transform.rotation.y', + 'transforms[:].transform.rotation.z', + 'transforms[:].transform.rotation.w', + ]); + }); + it('detects Float64MultiArray', () => { expect(detectPlotPaths({ schemaName: 'std_msgs/msg/Float64MultiArray' })).toEqual([ { path: 'data[:]', label: 'data' }, diff --git a/src/features/panels/Plot/autoDetect.ts b/src/features/panels/Plot/autoDetect.ts index 6e03a5e..5f75b34 100644 --- a/src/features/panels/Plot/autoDetect.ts +++ b/src/features/panels/Plot/autoDetect.ts @@ -9,11 +9,13 @@ import { poseAdapter, scalarAdapter, scalarGroupAdapter, + tfMessageAdapter, twistAdapter, validateDetectedPaths, vector3GroupAdapter, wrenchAdapter, } from './adapters'; +import { discoverNumericPlotFields } from './fieldDiscovery'; import { lookupPlotSchema } from './schemaRegistry/plotSchemaRegistry'; import type { AdapterContext, DetectedPlotPath, PlotAdapterId, PlotTypeAdapter } from './schemaRegistry/types'; @@ -32,6 +34,7 @@ const ADAPTERS: Record = { pose: poseAdapter, wrench: wrenchAdapter, odometry: odometryAdapter, + tfMessage: tfMessageAdapter, }; export function detectPlotPaths(args: { @@ -43,14 +46,26 @@ export function detectPlotPaths(args: { if (!schemaName) return []; const entry = lookupPlotSchema(schemaName); - if (!entry) return []; + if (!entry) { + return discoverNumericPlotFields(sample).map((field) => ({ + path: field.path, + label: field.label, + })); + } 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; + if (validated.length > 0) return validated; + if (!sample) return paths; + + const discovered = discoverNumericPlotFields(sample).map((field) => ({ + path: field.path, + label: field.label, + })); + return discovered.length > 0 ? discovered : paths; } export function getPreferredXAxisMode(schemaName?: string) { diff --git a/src/features/panels/Plot/datasets.test.ts b/src/features/panels/Plot/datasets.test.ts index 8424607..6b872d9 100644 --- a/src/features/panels/Plot/datasets.test.ts +++ b/src/features/panels/Plot/datasets.test.ts @@ -67,6 +67,71 @@ describe('buildPlotDataset', () => { expect(dataset.data[2]).toEqual([0.2, 0.4]); }); + it('builds separate runtime series from comma-separated scalar paths', () => { + const config = { + ...defaultPlotConfig(), + series: [{ + ...defaultPlotConfig().series[0], + id: 'pose', + topic: '/pose', + path: 'pose.position.x,pose.position.y,pose.position.z', + timestampMode: 'receiveTime' as const, + }], + }; + const dataset = buildPlotDataset( + [ + event('/pose', 1, { pose: { position: { x: 1, y: 2, z: 3 } } }, 'geometry_msgs/msg/PoseStamped'), + event('/pose', 2, { pose: { position: { x: 4, y: 5, z: 6 } } }, 'geometry_msgs/msg/PoseStamped'), + ], + config, + ); + expect(dataset.series.map((series) => series.label)).toEqual([ + 'pose.position.x', + 'pose.position.y', + 'pose.position.z', + ]); + expect(dataset.data[1]).toEqual([1, 4]); + expect(dataset.data[2]).toEqual([2, 5]); + expect(dataset.data[3]).toEqual([3, 6]); + }); + + it('keeps TFMessage transform curves stable by child_frame_id', () => { + const config = { + ...defaultPlotConfig(), + series: [{ + ...defaultPlotConfig().series[0], + id: 'tf', + topic: '/tf', + path: 'transforms[:].transform.translation.x', + timestampMode: 'receiveTime' as const, + }], + }; + const dataset = buildPlotDataset( + [ + event('/tf', 1, { + transforms: [ + { child_frame_id: 'link_A', transform: { translation: { x: 1 } } }, + { child_frame_id: 'link_B', transform: { translation: { x: 10 } } }, + ], + }, 'tf2_msgs/msg/TFMessage'), + event('/tf', 2, { + transforms: [ + { child_frame_id: 'link_B', transform: { translation: { x: 20 } } }, + { child_frame_id: 'link_A', transform: { translation: { x: 2 } } }, + ], + }, 'tf2_msgs/msg/TFMessage'), + ], + config, + ); + + expect(dataset.series.map((series) => series.label)).toEqual([ + 'transforms[0] (link_A).transform.translation.x', + 'transforms[1] (link_B).transform.translation.x', + ]); + expect(dataset.data[1]).toEqual([1, 2]); + expect(dataset.data[2]).toEqual([10, 20]); + }); + it('builds bounded JointState slices with Foxglove inclusive bounds', () => { const config = { ...defaultPlotConfig(), diff --git a/src/features/panels/Plot/fieldDiscovery.test.ts b/src/features/panels/Plot/fieldDiscovery.test.ts new file mode 100644 index 0000000..1d5c3c7 --- /dev/null +++ b/src/features/panels/Plot/fieldDiscovery.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { discoverNumericPlotFields } from './fieldDiscovery'; + +const poseStampedSample = { + header: { + stamp: { sec: 1773650610, nsec: 100884418 }, + frame_id: 'base_link', + }, + pose: { + position: { x: -0.14, y: 0.48, z: 0.21 }, + orientation: { x: 0.65, y: 0.69, z: -0.19, w: 0.24 }, + }, +}; + +describe('discoverNumericPlotFields', () => { + it('discovers nested PoseStamped numeric leaves and skips header metadata', () => { + const paths = discoverNumericPlotFields(poseStampedSample).map((field) => field.path); + expect(paths).toContain('pose.position.x'); + expect(paths).toContain('pose.position.y'); + expect(paths).toContain('pose.position.z'); + expect(paths).toContain('pose.orientation.w'); + expect(paths).not.toContain('header.stamp.sec'); + expect(paths).not.toContain('header.frame_id'); + }); + + it('discovers booleans, numeric strings, and numeric arrays', () => { + const fields = discoverNumericPlotFields({ + enabled: true, + gain: '1.25', + samples: [1, 2, 3], + label: 'not numeric', + }); + expect(fields.map((field) => field.path)).toEqual(expect.arrayContaining([ + 'samples[:]', + 'enabled', + 'gain', + ])); + }); + + it('does not expand object arrays into many accidental curves', () => { + const fields = discoverNumericPlotFields({ + poses: [{ x: 1 }, { x: 2 }], + value: 3, + }); + expect(fields.map((field) => field.path)).toEqual(['value']); + }); +}); diff --git a/src/features/panels/Plot/fieldDiscovery.ts b/src/features/panels/Plot/fieldDiscovery.ts new file mode 100644 index 0000000..dd9356c --- /dev/null +++ b/src/features/panels/Plot/fieldDiscovery.ts @@ -0,0 +1,171 @@ +export type PlotDiscoveredFieldKind = 'scalar' | 'array'; + +export interface PlotDiscoveredField { + path: string; + label: string; + kind: PlotDiscoveredFieldKind; + depth: number; + recommended: boolean; +} + +export interface DiscoverNumericPlotFieldsOptions { + maxDepth?: number; + maxFields?: number; + maxArrayLength?: number; +} + +const DEFAULT_MAX_DEPTH = 8; +const DEFAULT_MAX_FIELDS = 120; +const DEFAULT_MAX_ARRAY_LENGTH = 512; + +const SKIPPED_PATHS = new Set([ + 'header.stamp', + 'header.frame_id', +]); + +const SKIPPED_FIELD_NAMES = new Set([ + 'frame_id', + 'encoding', + 'format', + 'data_offset', +]); + +const RECOMMENDED_FIELD_NAMES = new Set([ + 'x', + 'y', + 'z', + 'w', + 'position', + 'orientation', + 'linear', + 'angular', + 'velocity', + 'effort', + 'temperature', + 'voltage', + 'percentage', + 'range', + 'force', + 'torque', +]); + +function isArrayLike(value: unknown): value is ArrayLike { + if (Array.isArray(value)) return true; + return ArrayBuffer.isView(value) && !(value instanceof DataView); +} + +function isNumericScalar(value: unknown): boolean { + if (typeof value === 'number') return Number.isFinite(value); + if (typeof value === 'bigint' || typeof value === 'boolean') return true; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 && Number.isFinite(Number(trimmed)); + } + return false; +} + +function pathDepth(path: string): number { + return path ? path.split('.').length : 0; +} + +function isSkippedPath(path: string): boolean { + if (SKIPPED_PATHS.has(path)) return true; + const leaf = path.split('.').at(-1) ?? ''; + return SKIPPED_FIELD_NAMES.has(leaf); +} + +function recommendationScore(path: string): number { + const parts = path.toLowerCase().split('.'); + let score = 0; + for (const part of parts) { + if (RECOMMENDED_FIELD_NAMES.has(part)) score += 4; + } + if (parts.some((part) => part === 'position')) score += 8; + if (parts.some((part) => part === 'linear' || part === 'angular')) score += 5; + if (parts.at(-1) === 'x') score += 3; + if (parts.at(-1) === 'y') score += 2; + if (parts.at(-1) === 'z') score += 1; + return score; +} + +function isRecommended(path: string): boolean { + return recommendationScore(path) > 0; +} + +function pushField(fields: PlotDiscoveredField[], field: PlotDiscoveredField, maxFields: number) { + if (fields.length >= maxFields) return; + fields.push(field); +} + +function walkValue( + value: unknown, + path: string, + fields: PlotDiscoveredField[], + options: Required, +): void { + if (!path || fields.length >= options.maxFields || isSkippedPath(path)) return; + + if (isNumericScalar(value)) { + pushField(fields, { + path, + label: path, + kind: 'scalar', + depth: pathDepth(path), + recommended: isRecommended(path), + }, options.maxFields); + return; + } + + if (isArrayLike(value)) { + const length = Math.min(value.length, options.maxArrayLength); + if (length === 0) return; + let numericCount = 0; + for (let i = 0; i < length; i++) { + if (isNumericScalar(value[i])) numericCount++; + } + if (numericCount === length) { + pushField(fields, { + path: `${path}[:]`, + label: path, + kind: 'array', + depth: pathDepth(path), + recommended: isRecommended(path), + }, options.maxFields); + } + return; + } + + if (!value || typeof value !== 'object' || pathDepth(path) >= options.maxDepth) return; + + for (const [key, child] of Object.entries(value as Record)) { + if (fields.length >= options.maxFields) return; + const childPath = `${path}.${key}`; + walkValue(child, childPath, fields, options); + } +} + +export function discoverNumericPlotFields( + sample: unknown, + opts: DiscoverNumericPlotFieldsOptions = {}, +): PlotDiscoveredField[] { + if (!sample || typeof sample !== 'object') return []; + const options: Required = { + maxDepth: opts.maxDepth ?? DEFAULT_MAX_DEPTH, + maxFields: opts.maxFields ?? DEFAULT_MAX_FIELDS, + maxArrayLength: opts.maxArrayLength ?? DEFAULT_MAX_ARRAY_LENGTH, + }; + + const fields: PlotDiscoveredField[] = []; + for (const [key, value] of Object.entries(sample as Record)) { + walkValue(value, key, fields, options); + if (fields.length >= options.maxFields) break; + } + + return fields.sort((a, b) => { + const scoreDiff = Number(b.recommended) - Number(a.recommended); + if (scoreDiff !== 0) return scoreDiff; + const priorityDiff = recommendationScore(b.path) - recommendationScore(a.path); + if (priorityDiff !== 0) return priorityDiff; + return a.path.localeCompare(b.path); + }); +} diff --git a/src/features/panels/Plot/messagePath.test.ts b/src/features/panels/Plot/messagePath.test.ts index 5402822..ecb7ebe 100644 --- a/src/features/panels/Plot/messagePath.test.ts +++ b/src/features/panels/Plot/messagePath.test.ts @@ -34,6 +34,40 @@ describe('extractPlotPathValues', () => { ]); }); + it('maps TFMessage transforms by child_frame_id', () => { + const message = { + transforms: [ + { + child_frame_id: 'link_Hips_R', + transform: { translation: { x: 0.1 } }, + }, + { + child_frame_id: 'link_Knee_R', + transform: { translation: { x: 0.2 } }, + }, + ], + }; + expect(extractPlotPathValues(message, 'transforms[:].transform.translation.x')).toEqual([ + { + key: 'transforms[link_Hips_R].transform.translation.x', + label: 'transforms[0] (link_Hips_R).transform.translation.x', + value: 0.1, + }, + { + key: 'transforms[link_Knee_R].transform.translation.x', + label: 'transforms[1] (link_Knee_R).transform.translation.x', + value: 0.2, + }, + ]); + expect(extractPlotPathValues(message, 'transforms[link_Knee_R].transform.translation.x')).toEqual([ + { + key: 'transforms[link_Knee_R].transform.translation.x', + label: 'transforms[1] (link_Knee_R).transform.translation.x', + value: 0.2, + }, + ]); + }); + it('applies math modifiers', () => { expect(extractPlotPathValues({ data: -2 }, 'data@abs')).toEqual([ { key: 'data', label: 'data', value: 2 }, diff --git a/src/features/panels/Plot/messagePath.ts b/src/features/panels/Plot/messagePath.ts index ef1a336..a4e32b2 100644 --- a/src/features/panels/Plot/messagePath.ts +++ b/src/features/panels/Plot/messagePath.ts @@ -83,6 +83,17 @@ function readNames(message: unknown): string[] { return out; } +function readArrayItemName(value: ArrayLike, index: number, message: unknown): string | undefined { + const rootNames = readNames(message); + const rootName = rootNames[index]; + if (rootName) return rootName; + + const item = value[index]; + if (!item || typeof item !== 'object') return undefined; + const childFrameId = (item as Record).child_frame_id; + return typeof childFrameId === 'string' && childFrameId.length > 0 ? childFrameId : undefined; +} + /** `position[1-2]` — hyphen range (both ends inclusive, same as `position[1:2]`). */ const SLICE_HYPHEN_RANGE_RE = /^(-?\d+)-(-?\d+)$/; /** `position[2-]` — hyphen open end (same as `position[2:]`). */ @@ -187,7 +198,9 @@ function selectorItems( } if (selector.kind === 'name') { - const names = readNames(message); + const names = Array.from({ length: value.length }, (_, index) => + readArrayItemName(value, index, message) ?? `${index}`, + ); const index = names.indexOf(selector.name); return index < 0 || index >= value.length ? [] @@ -201,10 +214,9 @@ function selectorItems( const bounds = resolveSliceBounds(selector, value.length); if (!bounds) return []; const { startIdx, endIdx } = bounds; - const names = readNames(message); const out: Array<{ key: string; label: string; value: unknown }> = []; for (let i = startIdx; i <= endIdx; i++) { - const name = names[i]; + const name = readArrayItemName(value, i, message); const label = name ? `${field}[${i}] (${name})` : `${field}[${i}]`; const key = name ? `${field}[${name}]` : `${field}[${i}]`; out.push({ key, label, value: value[i] }); diff --git a/src/features/panels/Plot/plotChart.test.ts b/src/features/panels/Plot/plotChart.test.ts index ccce6e1..12aa0b8 100644 --- a/src/features/panels/Plot/plotChart.test.ts +++ b/src/features/panels/Plot/plotChart.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + clampScaleToRange, computeRelativeTimeSplits, formatPlotXAxisTicks, formatPlotXValue, formatPlotYValue, + panScale, pickRelativeTimeIncrement, + zoomScaleAroundCursor, } from './plotChart'; describe('plotChart formatting', () => { @@ -61,3 +64,44 @@ describe('relative time axis splits', () => { ]); }); }); + +describe('plot chart viewport helpers', () => { + it('zooms around the cursor value', () => { + expect(zoomScaleAroundCursor({ min: 0, max: 10 }, 2.5, 0.5)).toEqual({ + min: 1.25, + max: 6.25, + }); + }); + + it('zooms Y ranges around the cursor value without requiring a full range', () => { + expect(zoomScaleAroundCursor({ min: -10, max: 10 }, 5, 0.5)).toEqual({ + min: -2.5, + max: 7.5, + }); + }); + + it('pans with pixel deltas and clamps to the full range', () => { + expect(panScale({ min: 20, max: 40 }, 50, 100, { min: 0, max: 100 })).toEqual({ + min: 10, + max: 30, + }); + expect(panScale({ min: 0, max: 20 }, 50, 100, { min: 0, max: 100 })).toEqual({ + min: 0, + max: 20, + }); + }); + + it('pans Y ranges without full-range clamping', () => { + expect(panScale({ min: -10, max: 10 }, -25, 100)).toEqual({ + min: -5, + max: 15, + }); + }); + + it('clamps oversized views to the full range', () => { + expect(clampScaleToRange({ min: -10, max: 120 }, { min: 0, max: 100 })).toEqual({ + min: 0, + max: 100, + }); + }); +}); diff --git a/src/features/panels/Plot/plotChart.ts b/src/features/panels/Plot/plotChart.ts index fd8a124..82e4c93 100644 --- a/src/features/panels/Plot/plotChart.ts +++ b/src/features/panels/Plot/plotChart.ts @@ -73,6 +73,76 @@ export function formatPlotXValue( return value.toFixed(3); } +export interface PlotScaleRange { + min: number; + max: number; +} + +export function clampScaleToRange( + scale: PlotScaleRange, + fullRange?: PlotScaleRange, + minWidth = 1e-6, +): PlotScaleRange { + let min = Math.min(scale.min, scale.max - minWidth); + let max = Math.max(scale.max, min + minWidth); + + if (!fullRange) return { min, max }; + + const fullWidth = fullRange.max - fullRange.min; + const width = max - min; + if (!Number.isFinite(fullWidth) || fullWidth <= 0 || width >= fullWidth) { + return { ...fullRange }; + } + + if (min < fullRange.min) { + max += fullRange.min - min; + min = fullRange.min; + } + if (max > fullRange.max) { + min -= max - fullRange.max; + max = fullRange.max; + } + + return { + min: Math.max(fullRange.min, min), + max: Math.min(fullRange.max, max), + }; +} + +export function zoomScaleAroundCursor( + scale: PlotScaleRange, + cursorVal: number, + factor: number, + fullRange?: PlotScaleRange, +): PlotScaleRange { + const width = scale.max - scale.min; + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(cursorVal)) return scale; + const safeFactor = Math.min(100, Math.max(0.01, factor)); + const ratio = Math.min(1, Math.max(0, (cursorVal - scale.min) / width)); + const nextWidth = width * safeFactor; + return clampScaleToRange({ + min: cursorVal - nextWidth * ratio, + max: cursorVal + nextWidth * (1 - ratio), + }, fullRange); +} + +export function panScale( + scale: PlotScaleRange, + deltaPx: number, + plotWidth: number, + fullRange?: PlotScaleRange, +): PlotScaleRange { + const width = scale.max - scale.min; + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(deltaPx) || plotWidth <= 0) { + return scale; + } + const deltaVal = -(deltaPx / plotWidth) * width; + return clampScaleToRange({ + min: scale.min + deltaVal, + max: scale.max + deltaVal, + }, fullRange); +} + /** uPlot X-axis tick labels; reuses formatPlotXValue so ticks match hover labels. */ export function formatPlotXAxisTicks( splits: number[], @@ -379,7 +449,7 @@ export function createPlotUplotOptions( }, ], cursor: { - drag: { setScale: true }, + drag: { setScale: false }, x: true, y: true, points: { show: false }, diff --git a/src/features/panels/Plot/plotLegendVisibility.test.ts b/src/features/panels/Plot/plotLegendVisibility.test.ts index 9bcf4bd..717c10e 100644 --- a/src/features/panels/Plot/plotLegendVisibility.test.ts +++ b/src/features/panels/Plot/plotLegendVisibility.test.ts @@ -5,8 +5,10 @@ import { plotLegendSelectionState, pruneHiddenLegendKeys, setAllPlotLegendVisible, + setOnlyPlotLegendVisible, setPlotLegendGroupVisible, setPlotLegendVisible, + visiblePlotLegendCount, } from './plotLegendVisibility'; describe('plotLegendVisibility', () => { @@ -38,6 +40,18 @@ describe('plotLegendVisibility', () => { ]); }); + it('shows only one legend entry within a group', () => { + expect(setOnlyPlotLegendVisible(['s2:x'], ['s1:a', 's1:b', 's1:c'], 's1:b')).toEqual([ + 's2:x', + 's1:a', + 's1:c', + ]); + }); + + it('counts visible entries', () => { + expect(visiblePlotLegendCount(entries, ['b'])).toBe(2); + }); + it('maps hidden keys to chart series indices', () => { expect([...hiddenSeriesIndices(entries, ['b'])]).toEqual([1]); }); diff --git a/src/features/panels/Plot/plotLegendVisibility.ts b/src/features/panels/Plot/plotLegendVisibility.ts index b2af50d..83ccd86 100644 --- a/src/features/panels/Plot/plotLegendVisibility.ts +++ b/src/features/panels/Plot/plotLegendVisibility.ts @@ -35,6 +35,27 @@ export function setPlotLegendGroupVisible( return [...next]; } +/** Hide every legend entry in the provided group except one target key. */ +export function setOnlyPlotLegendVisible( + hiddenKeys: readonly string[], + groupKeys: readonly string[], + key: string, +): string[] { + const group = new Set(groupKeys); + const next = new Set(hiddenKeys.filter((entry) => !group.has(entry))); + for (const entry of groupKeys) { + if (entry !== key) next.add(entry); + } + return [...next]; +} + +export function visiblePlotLegendCount( + entries: readonly PlotLegendEntry[], + hiddenKeys: readonly string[], +): number { + return entries.filter((entry) => isPlotLegendVisible(hiddenKeys, entry.key)).length; +} + export function pruneHiddenLegendKeys( hiddenKeys: readonly string[], validKeys: readonly string[], diff --git a/src/features/panels/Plot/plotSchemaRegistry.test.ts b/src/features/panels/Plot/plotSchemaRegistry.test.ts index 809ea74..5407783 100644 --- a/src/features/panels/Plot/plotSchemaRegistry.test.ts +++ b/src/features/panels/Plot/plotSchemaRegistry.test.ts @@ -15,6 +15,7 @@ import { filterPlottableTopics } from './plottableSchemas'; describe('plotSchemaRegistry', () => { it('includes JointState and excludes Image', () => { expect(isPlottableSchema('sensor_msgs/msg/JointState')).toBe(true); + expect(isPlottableSchema('tf2_msgs/msg/TFMessage')).toBe(true); expect(isPlottableSchema('sensor_msgs/msg/Image')).toBe(false); expect(isPlottableSchema('sensor_msgs/msg/CompressedImage')).toBe(false); expect(isPlottableSchema('sensor_msgs/msg/PointCloud2')).toBe(false); @@ -23,7 +24,9 @@ describe('plotSchemaRegistry', () => { it('assigns highest priority to JointState', () => { const joint = lookupPlotSchema('sensor_msgs/JointState'); const imu = lookupPlotSchema('sensor_msgs/Imu'); + const tf = lookupPlotSchema('tf2_msgs/msg/TFMessage'); expect(joint?.defaultPriority).toBeGreaterThan(imu?.defaultPriority ?? 0); + expect(imu?.defaultPriority).toBeGreaterThan(tf?.defaultPriority ?? 0); }); }); @@ -96,12 +99,21 @@ describe('pickDefaultPlotTopic', () => { it('prefers JointState over other plottable topics', () => { const topic = pickDefaultPlotTopic([ { name: '/imu', type: 'sensor_msgs/msg/Imu' }, + { name: '/tf', type: 'tf2_msgs/msg/TFMessage' }, { name: '/joint_states', type: 'sensor_msgs/msg/JointState' }, { name: '/camera/image', type: 'sensor_msgs/msg/Image' }, ]); expect(topic).toBe('/joint_states'); }); + it('can default to TFMessage when it is the only plottable topic', () => { + const topic = pickDefaultPlotTopic([ + { name: '/tf', type: 'tf2_msgs/msg/TFMessage' }, + { name: '/camera/image', type: 'sensor_msgs/msg/Image' }, + ]); + expect(topic).toBe('/tf'); + }); + it('filters to plottable topics only', () => { const filtered = filterPlottableTopics([ { name: '/camera', type: 'sensor_msgs/msg/Image' }, diff --git a/src/features/panels/Plot/plotTopicService.test.ts b/src/features/panels/Plot/plotTopicService.test.ts new file mode 100644 index 0000000..1d720ac --- /dev/null +++ b/src/features/panels/Plot/plotTopicService.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { detectPlotSeriesForTopic } from './plotTopicService'; + +const poseStampedSample = { + header: { + stamp: { sec: 1773650610, nsec: 100884418 }, + frame_id: 'base_link', + }, + pose: { + position: { x: -0.14, y: 0.48, z: 0.21 }, + orientation: { x: 0.65, y: 0.69, z: -0.19, w: 0.24 }, + }, +}; + +const tfMessageSample = { + transforms: [{ + header: { + stamp: { sec: 1739868633, nsec: 389101565 }, + frame_id: 'base_link', + }, + child_frame_id: 'link_Hips_R', + transform: { + translation: { x: 0, y: 0, z: 0.9712 }, + rotation: { x: 0.0338, y: 0, z: 0, w: 0.9994 }, + }, + }], +}; + +describe('detectPlotSeriesForTopic', () => { + it('does not let JointState defaults overwrite PoseStamped detected paths', () => { + const result = detectPlotSeriesForTopic({ + topic: '/pose', + schemaName: 'geometry_msgs/msg/PoseStamped', + sample: poseStampedSample, + existingSeriesId: 's1', + jointStateFields: ['position'], + }); + + expect(result.series[0]?.path).toBe('pose.position.x,pose.position.y,pose.position.z'); + expect(result.series[0]?.path).not.toBe('position[:]'); + }); + + it('keeps JointState fields as combined array paths', () => { + const result = detectPlotSeriesForTopic({ + topic: '/joint_states', + schemaName: 'sensor_msgs/msg/JointState', + sample: { name: ['a'], position: [1], velocity: [2] }, + existingSeriesId: 's1', + jointStateFields: ['position', 'velocity'], + }); + + expect(result.series[0]?.path).toBe('position[:],velocity[:]'); + }); + + it('uses sample discovery for unknown schemas', () => { + const result = detectPlotSeriesForTopic({ + topic: '/custom', + schemaName: 'custom_msgs/msg/Foo', + sample: { foo: { bar: { x: 1, y: 2 } } }, + existingSeriesId: 's1', + }); + + expect(result.series[0]?.path).toBe('foo.bar.x,foo.bar.y'); + }); + + it('uses TFMessage translation axes as the default path', () => { + const result = detectPlotSeriesForTopic({ + topic: '/tf', + schemaName: 'tf2_msgs/msg/TFMessage', + sample: tfMessageSample, + existingSeriesId: 's1', + }); + + expect(result.series[0]?.path).toBe( + 'transforms[:].transform.translation.x,transforms[:].transform.translation.y,transforms[:].transform.translation.z', + ); + expect(result.series[0]?.label).toBe('translation.x, translation.y, translation.z'); + }); +}); diff --git a/src/features/panels/Plot/plotTopicService.ts b/src/features/panels/Plot/plotTopicService.ts index 61b95ab..f5bdaef 100644 --- a/src/features/panels/Plot/plotTopicService.ts +++ b/src/features/panels/Plot/plotTopicService.ts @@ -1,11 +1,13 @@ import type { Player } from '@/core/types/player'; import type { Time } from '@/core/types/ros'; +import { isJointStateSchema } from '@/shared/ros/rosMessageTypes'; import { fromNano, toNano } from '@/shared/utils/time'; import { detectPlotPaths, getPreferredXAxisMode } from './autoDetect'; import { isPlottableSchema } from './plottableSchemas'; import type { JointStateField, PlotSeriesConfig, PlotXAxisMode } from './defaults'; import { createPlotSeries, paletteColor } from './defaults'; import { buildJointStateCombinedPath } from './jointStatePaths'; +import { isArrayLikePlotPath } from './messagePath'; import { mergeDetectedSeries, rebuildJointStateSeries } from './topicPaths'; export { mergeDetectedSeries, rebuildJointStateSeries }; @@ -23,6 +25,47 @@ export interface BuildSeriesResult { xAxisMode?: PlotXAxisMode; } +function commonXAxisPath(paths: ReturnType): string { + const xAxisPaths = paths + .map((entry) => entry.xAxisPath ?? '') + .filter((path) => path.trim().length > 0); + if (xAxisPaths.length === 0) return ''; + const [first] = xAxisPaths; + return xAxisPaths.every((path) => path === first) ? first : ''; +} + +function defaultPathCandidates(paths: ReturnType) { + const defaults = paths.filter((entry) => entry.default !== false); + return defaults.length > 0 ? defaults : paths; +} + +function firstSlicePrefix(path: string): string { + const match = /(^|\.)([A-Za-z_$][\w$]*\[[^\]]*(?::|-)[^\]]*\])/.exec(path); + return match?.[2] ?? ''; +} + +function defaultDetectedPath(paths: ReturnType): string { + const candidates = defaultPathCandidates(paths); + if (candidates.length === 0) return ''; + const arrayEntries = candidates.filter((entry) => isArrayLikePlotPath(entry.path)); + if (arrayEntries.length > 1) { + const prefixes = new Set(arrayEntries.map((entry) => firstSlicePrefix(entry.path)).filter(Boolean)); + if (prefixes.size === 1 && arrayEntries.length === candidates.length) { + return candidates.map((entry) => entry.path).filter(Boolean).join(','); + } + } + const arrayEntry = arrayEntries[0]; + if (arrayEntry) return arrayEntry.path; + return candidates.map((entry) => entry.path).filter(Boolean).join(','); +} + +function defaultDetectedLabel(paths: ReturnType): string { + return defaultPathCandidates(paths) + .map((entry) => entry.label ?? entry.path) + .filter(Boolean) + .join(', '); +} + export async function sampleTopicMessage(args: { player: Player; topic: string; @@ -64,7 +107,14 @@ export function detectPlotSeriesForTopic(args: { }; } - const detected = detectPlotPaths({ schemaName, sample, jointStateFields }); + const selectedJointStateFields = isJointStateSchema(schemaName) && (jointStateFields?.length ?? 0) > 0 + ? jointStateFields + : undefined; + const detected = detectPlotPaths({ + schemaName, + sample, + jointStateFields: selectedJointStateFields, + }); const entry = detected[0]; if (!entry) { return { @@ -79,17 +129,17 @@ export function detectPlotSeriesForTopic(args: { } const preferredXAxis = getPreferredXAxisMode(schemaName); - const path = jointStateFields?.length - ? buildJointStateCombinedPath(jointStateFields) - : entry.path; + const path = selectedJointStateFields + ? buildJointStateCombinedPath(selectedJointStateFields) + : defaultDetectedPath(detected); return { series: [ createPlotSeries({ id: existingSeriesId, topic, path, - xAxisPath: entry.xAxisPath ?? '', - label: entry.label ?? '', + xAxisPath: commonXAxisPath(detected), + label: defaultDetectedLabel(detected), color: paletteColor(0), }), ], diff --git a/src/features/panels/Plot/plottableSchemas.test.ts b/src/features/panels/Plot/plottableSchemas.test.ts new file mode 100644 index 0000000..bb5d3b6 --- /dev/null +++ b/src/features/panels/Plot/plottableSchemas.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { filterPlottableTopics, isPlottableSchema } from './plottableSchemas'; + +describe('Plot plottable schema filtering', () => { + it('allows unknown non-blocked schemas so sample discovery can run', () => { + expect(isPlottableSchema('custom_msgs/msg/Foo')).toBe(true); + expect(filterPlottableTopics([ + { name: '/custom', type: 'custom_msgs/msg/Foo' }, + { name: '/image', type: 'sensor_msgs/msg/Image' }, + ]).map((topic) => topic.name)).toEqual(['/custom']); + }); +}); diff --git a/src/features/panels/Plot/plottableSchemas.ts b/src/features/panels/Plot/plottableSchemas.ts index bf46c0e..248dbba 100644 --- a/src/features/panels/Plot/plottableSchemas.ts +++ b/src/features/panels/Plot/plottableSchemas.ts @@ -5,9 +5,6 @@ import { isRawAudioSchema, isRosImageSchema, } from '@/shared/ros/rosMessageTypes'; -import { isPlottableSchema } from './schemaRegistry/plotSchemaRegistry'; - -export { isPlottableSchema }; export function isBlockedPlotSchema(type: string): boolean { if (isRosImageSchema(type)) return true; @@ -27,7 +24,12 @@ export function isBlockedPlotSchema(type: string): boolean { export function isPlottableTopic(topic: TopicInfo): boolean { if (isBlockedPlotSchema(topic.type)) return false; - return isPlottableSchema(topic.type); + return true; +} + +export function isPlottableSchema(type: string): boolean { + if (isBlockedPlotSchema(type)) return false; + return true; } export function filterPlottableTopics(topics: ReadonlyArray): TopicInfo[] { diff --git a/src/features/panels/Plot/schemaRegistry/plotSchemaRegistry.ts b/src/features/panels/Plot/schemaRegistry/plotSchemaRegistry.ts index c8ac036..5a9307d 100644 --- a/src/features/panels/Plot/schemaRegistry/plotSchemaRegistry.ts +++ b/src/features/panels/Plot/schemaRegistry/plotSchemaRegistry.ts @@ -55,6 +55,8 @@ const ENTRIES: PlotSchemaEntry[] = [ { schemaSuffix: 'geometry_msgs/wrenchstamped', adapterId: 'wrench', defaultPriority: 65 }, // nav_msgs { schemaSuffix: 'nav_msgs/odometry', adapterId: 'odometry', defaultPriority: 80 }, + // tf2_msgs + { schemaSuffix: 'tf2_msgs/tfmessage', adapterId: 'tfMessage', defaultPriority: 20 }, ]; const REGISTRY = new Map( diff --git a/src/features/panels/Plot/schemaRegistry/types.ts b/src/features/panels/Plot/schemaRegistry/types.ts index e3b4ddc..75d2700 100644 --- a/src/features/panels/Plot/schemaRegistry/types.ts +++ b/src/features/panels/Plot/schemaRegistry/types.ts @@ -13,7 +13,8 @@ export type PlotAdapterId = | 'twist' | 'pose' | 'wrench' - | 'odometry'; + | 'odometry' + | 'tfMessage'; export interface PlotSchemaEntry { /** Normalized suffix, e.g. sensor_msgs/jointstate */ @@ -27,6 +28,8 @@ export interface DetectedPlotPath { path: string; label?: string; xAxisPath?: string; + /** When false, expose in field pickers but do not include in auto-created default Y path. */ + default?: boolean; } export interface AdapterContext { diff --git a/src/features/panels/Plot/usePlotChart.test.ts b/src/features/panels/Plot/usePlotChart.test.ts index b5dcb4d..d0a1757 100644 --- a/src/features/panels/Plot/usePlotChart.test.ts +++ b/src/features/panels/Plot/usePlotChart.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { diffSeriesTopology, + hasManualPlotViewport, + plotInteractionAxes, shouldPinPlotXScaleToLogRange, shouldRemountForIncrementalSeriesUpdate, type SeriesSignature, @@ -103,3 +105,18 @@ describe('shouldPinPlotXScaleToLogRange', () => { expect(shouldPinPlotXScaleToLogRange(logRange, 10)).toBe(false); }); }); + +describe('plot viewport interaction helpers', () => { + it('reports whether any axis is manually controlled', () => { + expect(hasManualPlotViewport({ x: false, y: false })).toBe(false); + expect(hasManualPlotViewport({ x: true, y: false })).toBe(true); + expect(hasManualPlotViewport({ x: false, y: true })).toBe(true); + }); + + it('maps modifiers to interaction axes', () => { + expect(plotInteractionAxes({ shiftKey: false, ctrlKey: false, metaKey: false })).toEqual(['x']); + expect(plotInteractionAxes({ shiftKey: true, ctrlKey: false, metaKey: false })).toEqual(['y']); + expect(plotInteractionAxes({ shiftKey: false, ctrlKey: true, metaKey: false })).toEqual(['x', 'y']); + expect(plotInteractionAxes({ shiftKey: false, ctrlKey: false, metaKey: true })).toEqual(['x', 'y']); + }); +}); diff --git a/src/features/panels/Plot/usePlotChart.ts b/src/features/panels/Plot/usePlotChart.ts index 1698c7b..35616b9 100644 --- a/src/features/panels/Plot/usePlotChart.ts +++ b/src/features/panels/Plot/usePlotChart.ts @@ -10,8 +10,11 @@ import { buildPlotSeriesOption, createPlotUplotOptions, mountPlotChart, + panScale, readPlotChartColors, resetPlotYScaleLock, + zoomScaleAroundCursor, + type PlotScaleRange, } from './plotChart'; export interface UsePlotChartArgs { @@ -30,6 +33,17 @@ export interface UsePlotChartArgs { * appending. */ loading?: boolean; + onViewportStateChange?: (state: PlotViewportState) => void; +} + +export interface PlotViewportState { + x: boolean; + y: boolean; +} + +export interface UsePlotChartResult { + chartRef: RefObject; + resetViewport: () => void; } interface SeriesSignature { @@ -190,6 +204,16 @@ export function pinPlotXScaleToLogRange( chart.setScale('x', xRange); } +export function hasManualPlotViewport(state: PlotViewportState): boolean { + return state.x || state.y; +} + +export function plotInteractionAxes(event: Pick): Array<'x' | 'y'> { + if (event.ctrlKey || event.metaKey) return ['x', 'y']; + if (event.shiftKey) return ['y']; + return ['x']; +} + export function usePlotChart({ containerRef, player, @@ -200,7 +224,8 @@ export function usePlotChart({ xRange, logStart, loading, -}: UsePlotChartArgs): RefObject { + onViewportStateChange, +}: UsePlotChartArgs): UsePlotChartResult { const uplotRef = useRef(null); const currentTimeSecRef = useRef( (() => { @@ -213,12 +238,166 @@ export function usePlotChart({ const xAxisModeRef = useRef(config.xAxisMode); const seriesSignaturesRef = useRef([]); const resizeObserverRef = useRef(null); + const interactionCleanupRef = useRef<(() => void) | null>(null); const loadingRef = useRef(!!loading); + const manualViewportRef = useRef({ x: false, y: false }); + const xRangeRef = useRef(xRange); + const onViewportStateChangeRef = useRef(onViewportStateChange); useEffect(() => { loadingRef.current = !!loading; }, [loading]); + useEffect(() => { + xRangeRef.current = xRange; + }, [xRange]); + useEffect(() => { + onViewportStateChangeRef.current = onViewportStateChange; + }, [onViewportStateChange]); + + const notifyViewportState = useCallback(() => { + onViewportStateChangeRef.current?.({ ...manualViewportRef.current }); + }, []); + + const markManualViewport = useCallback((axis: 'x' | 'y') => { + const prev = manualViewportRef.current; + if (prev[axis]) return; + manualViewportRef.current = { ...prev, [axis]: true }; + notifyViewportState(); + }, [notifyViewportState]); + + const resetViewport = useCallback(() => { + const chart = uplotRef.current; + manualViewportRef.current = { x: false, y: false }; + notifyViewportState(); + if (!chart) return; + + resetPlotYScaleLock(chart); + chart.setData(chart.data, true); + + if (config.xAxisMode === 'timestamp' && config.followingViewWidthSec > 0) { + const end = currentTimeSecRef.current; + if (end != null && Number.isFinite(end)) { + chart.setScale('x', { min: end - config.followingViewWidthSec, max: end }); + } + } else if (xRangeRef.current) { + chart.setScale('x', xRangeRef.current); + } + }, [config.followingViewWidthSec, config.xAxisMode, notifyViewportState]); + + const attachChartInteractions = useCallback((chart: uPlot) => { + const over = chart.over; + let drag: + | { + pointerId: number; + clientX: number; + clientY: number; + scales: Partial>; + axes: Array<'x' | 'y'>; + moved: boolean; + } + | null = null; + + const currentScale = (axis: 'x' | 'y'): PlotScaleRange | undefined => { + const min = chart.scales[axis].min; + const max = chart.scales[axis].max; + return typeof min === 'number' && typeof max === 'number' ? { min, max } : undefined; + }; + + const setManualScale = (axis: 'x' | 'y', scale: PlotScaleRange) => { + markManualViewport(axis); + chart.setScale(axis, scale); + }; + + const onWheel = (event: WheelEvent) => { + const axes = plotInteractionAxes(event); + event.preventDefault(); + const rect = over.getBoundingClientRect(); + const factor = event.deltaY > 0 ? 1.18 : 1 / 1.18; + if (axes.includes('x')) { + const scale = currentScale('x'); + if (scale) { + const cursorVal = chart.posToVal(event.clientX - rect.left, 'x'); + setManualScale('x', zoomScaleAroundCursor(scale, cursorVal, factor, xRangeRef.current)); + } + } + if (axes.includes('y')) { + const scale = currentScale('y'); + if (scale) { + const cursorVal = chart.posToVal(event.clientY - rect.top, 'y'); + setManualScale('y', zoomScaleAroundCursor(scale, cursorVal, factor)); + } + } + }; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0 || event.altKey) return; + const axes = plotInteractionAxes(event); + const scales = Object.fromEntries( + axes.flatMap((axis) => { + const scale = currentScale(axis); + return scale ? [[axis, scale]] : []; + }), + ) as Partial>; + if (Object.keys(scales).length === 0) return; + drag = { + pointerId: event.pointerId, + clientX: event.clientX, + clientY: event.clientY, + scales, + axes, + moved: false, + }; + over.setPointerCapture(event.pointerId); + over.style.cursor = 'grabbing'; + event.preventDefault(); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!drag || drag.pointerId !== event.pointerId) return; + const deltaPx = event.clientX - drag.clientX; + const deltaY = event.clientY - drag.clientY; + if (Math.hypot(deltaPx, deltaY) < 2) return; + drag.moved = true; + if (drag.axes.includes('x') && drag.scales.x) { + setManualScale('x', panScale(drag.scales.x, deltaPx, chart.bbox.width, xRangeRef.current)); + } + if (drag.axes.includes('y') && drag.scales.y) { + setManualScale('y', panScale(drag.scales.y, -deltaY, chart.bbox.height)); + } + }; + + const finishDrag = (event: PointerEvent) => { + if (!drag || drag.pointerId !== event.pointerId) return; + over.releasePointerCapture(event.pointerId); + over.style.cursor = ''; + drag = null; + }; + + const onDoubleClick = (event: MouseEvent) => { + event.preventDefault(); + resetViewport(); + }; + + over.addEventListener('wheel', onWheel, { passive: false }); + over.addEventListener('pointerdown', onPointerDown); + over.addEventListener('pointermove', onPointerMove); + over.addEventListener('pointerup', finishDrag); + over.addEventListener('pointercancel', finishDrag); + over.addEventListener('dblclick', onDoubleClick); + + return () => { + over.removeEventListener('wheel', onWheel); + over.removeEventListener('pointerdown', onPointerDown); + over.removeEventListener('pointermove', onPointerMove); + over.removeEventListener('pointerup', finishDrag); + over.removeEventListener('pointercancel', finishDrag); + over.removeEventListener('dblclick', onDoubleClick); + over.style.cursor = ''; + }; + }, [markManualViewport, resetViewport]); const destroyChart = useCallback(() => { + interactionCleanupRef.current?.(); + interactionCleanupRef.current = null; resizeObserverRef.current?.disconnect(); resizeObserverRef.current = null; const chart = uplotRef.current; @@ -249,9 +428,11 @@ export function usePlotChart({ const chart = mountPlotChart(container, dataset, options, xRange); uplotRef.current = chart; + interactionCleanupRef.current = attachChartInteractions(chart); seriesSignaturesRef.current = seriesSignatures(dataset, hiddenSeries); if ( loadingRef.current + && !manualViewportRef.current.x && shouldPinPlotXScaleToLogRange(xRange, followingViewWidthRef.current) ) { pinPlotXScaleToLogRange(chart, xRange); @@ -267,7 +448,7 @@ export function usePlotChart({ }); observer.observe(container); resizeObserverRef.current = observer; - }, [config.xAxisMode, containerRef, dataset, destroyChart, hiddenSeries, logStart, panelId, xRange]); + }, [attachChartInteractions, config.xAxisMode, containerRef, dataset, destroyChart, hiddenSeries, logStart, panelId, xRange]); useEffect(() => { const container = containerRef.current; @@ -339,6 +520,7 @@ export function usePlotChart({ // range so the axis stays 0…duration while curves grow incrementally. if ( loadingRef.current + && !manualViewportRef.current.x && shouldPinPlotXScaleToLogRange(xRange, followingViewWidthRef.current) ) { pinPlotXScaleToLogRange(chart, xRange); @@ -365,7 +547,7 @@ export function usePlotChart({ // When loading completes, force one Y-scale recompute so the locked-min/max // is replaced with the natural auto-fit range. useEffect(() => { - if (loading) return; + if (loading || manualViewportRef.current.y) return; const chart = uplotRef.current; if (!chart) return; resetPlotYScaleLock(chart); @@ -384,7 +566,11 @@ export function usePlotChart({ const chart = uplotRef.current; if (!chart) return; - if (xAxisModeRef.current === 'timestamp' && followingViewWidthRef.current > 0) { + if ( + !manualViewportRef.current.x + && xAxisModeRef.current === 'timestamp' + && followingViewWidthRef.current > 0 + ) { const end = currentTimeSecRef.current; if (end != null && Number.isFinite(end)) { chart.setScale('x', { @@ -408,6 +594,7 @@ export function usePlotChart({ useEffect(() => { const chart = uplotRef.current; if (!chart || config.xAxisMode !== 'timestamp') return; + if (manualViewportRef.current.x) return; if (config.followingViewWidthSec > 0) { const end = currentTimeSecRef.current; if (end != null && Number.isFinite(end)) { @@ -426,5 +613,5 @@ export function usePlotChart({ } }, [dataset.series.length, hiddenSeries]); - return uplotRef; + return { chartRef: uplotRef, resetViewport }; } diff --git a/src/features/panels/Plot/usePlotTopicDetection.ts b/src/features/panels/Plot/usePlotTopicDetection.ts index 2a07158..0909bc7 100644 --- a/src/features/panels/Plot/usePlotTopicDetection.ts +++ b/src/features/panels/Plot/usePlotTopicDetection.ts @@ -2,7 +2,8 @@ import { useCallback, useRef, useState } from 'react'; import type { Player } from '@/core/types/player'; import type { Time } from '@/core/types/ros'; import type { TopicInfo } from '@/core/types/ros'; -import type { JointStateField, PlotConfig } from './defaults'; +import { isJointStateSchema } from '@/shared/ros/rosMessageTypes'; +import type { PlotConfig } from './defaults'; import { applyDetectedTopicToConfig, clearSeriesTopic, @@ -45,6 +46,9 @@ export function usePlotTopicDetection({ try { const isPrimary = seriesId === config.series[0]?.id; const schemaName = topicByName.get(topic)?.type; + const jointStateFields = schemaName && isJointStateSchema(schemaName) + ? config.jointStateFields + : undefined; const result = await buildSeriesForTopic({ topic, schemaName, @@ -52,7 +56,7 @@ export function usePlotTopicDetection({ startTime, endTime, existingSeriesId: seriesId, - jointStateFields: isPrimary ? config.jointStateFields : (['position'] as JointStateField[]), + jointStateFields, }); if (requestId !== requestIdRef.current) return; diff --git a/src/shared/intl/messages/en/panels.json b/src/shared/intl/messages/en/panels.json index 495491e..0cc8a07 100644 --- a/src/shared/intl/messages/en/panels.json +++ b/src/shared/intl/messages/en/panels.json @@ -2,6 +2,8 @@ "panels.image.defaultTitle": "Image", "panels.plot.defaultTitle": "Plot", "panels.plot.toolbar.selectTopic": "Select topic…", + "panels.plot.toolbar.resetZoom": "Reset zoom", + "panels.plot.toolbar.resetZoomAria": "Reset chart zoom", "panels.plot.status.detectingPaths": "Detecting paths…", "panels.plot.status.loading": "Loading…", "panels.plot.status.loadingProgress": "Loading {count} messages…", @@ -19,6 +21,17 @@ "panels.plot.settings.legend.empty": "Curve list appears after data is loaded", "panels.plot.settings.legend.selectedCount": "{visible} / {total} selected", "panels.plot.settings.legend.selectAllAria": "Select all curves for this series", + "panels.plot.legend.visibleCount": "{visible} / {total} curves", + "panels.plot.legend.expand": "Expand legend", + "panels.plot.legend.collapse": "Collapse legend", + "panels.plot.legend.searchPlaceholder": "Search curves…", + "panels.plot.legend.showAll": "Show all", + "panels.plot.legend.hideAll": "Hide all", + "panels.plot.legend.only": "Only", + "panels.plot.legend.onlyThis": "Show only this curve", + "panels.plot.legend.showCurve": "Show curve", + "panels.plot.legend.hideCurve": "Hide curve", + "panels.plot.legend.noMatches": "No matching curves", "panels.plot.settings.section.plot": "Plot", "panels.plot.settings.section.series": "Series", "panels.plot.settings.field.xAxis": "X axis", @@ -36,6 +49,10 @@ "panels.plot.settings.series.show": "Show series {index}", "panels.plot.settings.series.hide": "Hide series {index}", "panels.plot.settings.field.topic": "Topic", + "panels.plot.settings.field.topicFields": "Topic fields", + "panels.plot.settings.field.topicFields.help": "Select numeric message fields to plot.", + "panels.plot.settings.field.topicFields.loading": "Detecting fields…", + "panels.plot.settings.field.topicFields.empty": "No numeric fields detected.", "panels.plot.settings.field.yPath": "Y path", "panels.plot.settings.field.yPath.help": "Auto-detected when you pick a topic.", "panels.plot.settings.field.yPath.placeholder": "auto-detected", diff --git a/src/shared/intl/messages/ja/panels.json b/src/shared/intl/messages/ja/panels.json index 2a1e9d3..048592d 100644 --- a/src/shared/intl/messages/ja/panels.json +++ b/src/shared/intl/messages/ja/panels.json @@ -2,6 +2,8 @@ "panels.image.defaultTitle": "画像", "panels.plot.defaultTitle": "プロット", "panels.plot.toolbar.selectTopic": "トピックを選択…", + "panels.plot.toolbar.resetZoom": "ズームをリセット", + "panels.plot.toolbar.resetZoomAria": "グラフのズームをリセット", "panels.plot.status.detectingPaths": "パスを検出中…", "panels.plot.status.loading": "読み込み中…", "panels.plot.status.loadingProgress": "読み込み中 {count} 件", @@ -19,6 +21,17 @@ "panels.plot.settings.legend.empty": "データ読み込み後に曲線リストが表示されます", "panels.plot.settings.legend.selectedCount": "選択 {visible} / {total}", "panels.plot.settings.legend.selectAllAria": "この系列の曲線をすべて選択", + "panels.plot.legend.visibleCount": "{visible} / {total} 曲線", + "panels.plot.legend.expand": "凡例を展開", + "panels.plot.legend.collapse": "凡例を折りたたむ", + "panels.plot.legend.searchPlaceholder": "曲線を検索…", + "panels.plot.legend.showAll": "すべて表示", + "panels.plot.legend.hideAll": "すべて非表示", + "panels.plot.legend.only": "のみ", + "panels.plot.legend.onlyThis": "この曲線のみ表示", + "panels.plot.legend.showCurve": "曲線を表示", + "panels.plot.legend.hideCurve": "曲線を非表示", + "panels.plot.legend.noMatches": "一致する曲線がありません", "panels.plot.settings.section.plot": "プロット", "panels.plot.settings.section.series": "系列", "panels.plot.settings.field.xAxis": "X 軸", @@ -36,6 +49,10 @@ "panels.plot.settings.series.show": "系列 {index} を表示", "panels.plot.settings.series.hide": "系列 {index} を非表示", "panels.plot.settings.field.topic": "トピック", + "panels.plot.settings.field.topicFields": "トピックフィールド", + "panels.plot.settings.field.topicFields.help": "描画する数値メッセージフィールドを選択します。", + "panels.plot.settings.field.topicFields.loading": "フィールドを検出中…", + "panels.plot.settings.field.topicFields.empty": "数値フィールドが見つかりません。", "panels.plot.settings.field.yPath": "Y パス", "panels.plot.settings.field.yPath.help": "トピック選択時に自動検出されます。", "panels.plot.settings.field.yPath.placeholder": "自動検出", diff --git a/src/shared/intl/messages/zh/panels.json b/src/shared/intl/messages/zh/panels.json index 398a620..fc2825a 100644 --- a/src/shared/intl/messages/zh/panels.json +++ b/src/shared/intl/messages/zh/panels.json @@ -2,6 +2,8 @@ "panels.image.defaultTitle": "图像", "panels.plot.defaultTitle": "图表", "panels.plot.toolbar.selectTopic": "选择 Topic…", + "panels.plot.toolbar.resetZoom": "重置缩放", + "panels.plot.toolbar.resetZoomAria": "重置图表缩放", "panels.plot.status.detectingPaths": "检测路径…", "panels.plot.status.loading": "加载中…", "panels.plot.status.loadingProgress": "加载中 {count} 条", @@ -19,6 +21,17 @@ "panels.plot.settings.legend.empty": "加载数据后将显示曲线列表", "panels.plot.settings.legend.selectedCount": "已选 {visible} / {total}", "panels.plot.settings.legend.selectAllAria": "全选本序列的曲线", + "panels.plot.legend.visibleCount": "{visible} / {total} 条曲线", + "panels.plot.legend.expand": "展开图例", + "panels.plot.legend.collapse": "收起图例", + "panels.plot.legend.searchPlaceholder": "搜索曲线…", + "panels.plot.legend.showAll": "全部显示", + "panels.plot.legend.hideAll": "全部隐藏", + "panels.plot.legend.only": "仅看", + "panels.plot.legend.onlyThis": "只显示这条曲线", + "panels.plot.legend.showCurve": "显示曲线", + "panels.plot.legend.hideCurve": "隐藏曲线", + "panels.plot.legend.noMatches": "没有匹配的曲线", "panels.plot.settings.section.plot": "图表", "panels.plot.settings.section.series": "序列", "panels.plot.settings.field.xAxis": "X 轴", @@ -36,6 +49,10 @@ "panels.plot.settings.series.show": "显示序列 {index}", "panels.plot.settings.series.hide": "隐藏序列 {index}", "panels.plot.settings.field.topic": "Topic", + "panels.plot.settings.field.topicFields": "Topic 字段", + "panels.plot.settings.field.topicFields.help": "选择要绘制的数值消息字段。", + "panels.plot.settings.field.topicFields.loading": "正在检测字段…", + "panels.plot.settings.field.topicFields.empty": "未检测到数值字段。", "panels.plot.settings.field.yPath": "Y 路径", "panels.plot.settings.field.yPath.help": "选择 Topic 后自动检测。", "panels.plot.settings.field.yPath.placeholder": "自动检测",