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": "自动检测",