Skip to content

Commit 7b196d4

Browse files
authored
fix(plot): v1.5.1 legend, styles, and seek alignment (#10)
* fix(plot): legend toggle, live series styles, and seek alignment Stabilize activeTopics so legend changes do not restart range reads. Overlay line style/size/color from live config at dataset build time and apply uPlot style updates without remounting. Fix click-to-seek using the plot area (over) coordinates so seek matches hover labels. * chore(release): bump version to 1.5.1 * fix(plot): per-series legend UI with independent select-all Move curve visibility toggles into each series card so joint comparison across topics stays scoped per series. Fix select-all to only update keys for the active series. --------- Co-authored-by: joaner <joaner@users.noreply.github.com>
1 parent aba77c4 commit 7b196d4

18 files changed

Lines changed: 316 additions & 56 deletions

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ioai/rosview",
3-
"version": "1.5.0",
3+
"version": "1.5.1",
44
"description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA",
55
"keywords": [
66
"ros",

src/features/panels/Plot/PlotLegendSettings.tsx

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,46 @@
11
import React, { useEffect, useMemo, useRef } from 'react';
22
import { useIntl } from 'react-intl';
3-
import { SettingsSection } from '../framework/settings/SettingsPrimitives';
43
import { ScrollArea } from '@/shared/ui/scroll-area';
54
import type { PlotConfig } from './defaults';
65
import {
76
isPlotLegendVisible,
87
plotLegendSelectionState,
9-
setAllPlotLegendVisible,
8+
setPlotLegendGroupVisible,
109
setPlotLegendVisible,
1110
} from './plotLegendVisibility';
1211
import { usePlotLegendEntries } from './plotPanelRuntimeStore';
1312

1413
interface PlotLegendSettingsProps {
1514
panelId: string;
15+
seriesId: string;
1616
config: PlotConfig;
1717
setConfig: (next: PlotConfig | ((prev: PlotConfig) => PlotConfig)) => void;
1818
}
1919

20-
function legendInputId(panelId: string, index: number): string {
21-
return `plot-legend-${panelId}-${index}`;
20+
function legendKeyPrefix(seriesId: string): string {
21+
return `${seriesId}:`;
22+
}
23+
24+
function legendInputId(panelId: string, seriesId: string, index: number): string {
25+
return `plot-legend-${panelId}-${seriesId}-${index}`;
2226
}
2327

2428
export function PlotLegendSettings({
2529
panelId,
30+
seriesId,
2631
config,
2732
setConfig,
2833
}: PlotLegendSettingsProps): React.ReactNode {
2934
const { formatMessage } = useIntl();
30-
const entries = usePlotLegendEntries(panelId);
35+
const allEntries = usePlotLegendEntries(panelId);
36+
const prefix = legendKeyPrefix(seriesId);
37+
const entries = useMemo(
38+
() => allEntries.filter((entry) => entry.key.startsWith(prefix)),
39+
[allEntries, prefix],
40+
);
3141
const hiddenKeys = config.hiddenLegendKeys;
3242
const selectAllRef = useRef<HTMLInputElement>(null);
33-
const selectAllId = `plot-legend-${panelId}-all`;
43+
const selectAllId = `plot-legend-${panelId}-${seriesId}-all`;
3444

3545
const selection = useMemo(
3646
() => plotLegendSelectionState(entries, hiddenKeys),
@@ -48,6 +58,10 @@ export function PlotLegendSettings({
4858
input.indeterminate = selection === 'partial';
4959
}, [selection]);
5060

61+
if (entries.length <= 1) {
62+
return null;
63+
}
64+
5165
const setHiddenKeys = (next: string[]) => {
5266
setConfig((prev) => ({ ...prev, hiddenLegendKeys: next }));
5367
};
@@ -57,21 +71,25 @@ export function PlotLegendSettings({
5771
};
5872

5973
const toggleAll = (visible: boolean) => {
60-
setHiddenKeys(setAllPlotLegendVisible(entries.map((entry) => entry.key), visible));
74+
setHiddenKeys(
75+
setPlotLegendGroupVisible(
76+
hiddenKeys,
77+
entries.map((entry) => entry.key),
78+
visible,
79+
),
80+
);
6181
};
6282

6383
return (
64-
<SettingsSection
65-
title={formatMessage({ id: 'panels.plot.settings.section.legend' })}
66-
description={formatMessage({ id: 'panels.plot.settings.legend.description' })}
67-
>
68-
{entries.length === 0 ? (
69-
<p className="px-1 text-[11px] text-muted-foreground">
70-
{formatMessage({ id: 'panels.plot.settings.legend.empty' })}
71-
</p>
72-
) : (
73-
<>
74-
<div className="mb-1.5 flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
84+
<div className="space-y-1 border-t border-border pt-2">
85+
<p className="text-[10px] font-medium text-muted-foreground">
86+
{formatMessage({ id: 'panels.plot.settings.series.legend.title' })}
87+
</p>
88+
<p className="px-0.5 text-[10px] text-muted-foreground">
89+
{formatMessage({ id: 'panels.plot.settings.legend.description' })}
90+
</p>
91+
<>
92+
<div className="flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
7593
<input
7694
ref={selectAllRef}
7795
id={selectAllId}
@@ -88,10 +106,10 @@ export function PlotLegendSettings({
88106
)}
89107
</label>
90108
</div>
91-
<ScrollArea className="max-h-72 rounded border border-border">
109+
<ScrollArea className="max-h-48 rounded border border-border">
92110
<div className="flex flex-col py-0.5">
93111
{entries.map((entry, index) => {
94-
const inputId = legendInputId(panelId, index);
112+
const inputId = legendInputId(panelId, seriesId, index);
95113
const visible = isPlotLegendVisible(hiddenKeys, entry.key);
96114
return (
97115
<label
@@ -111,16 +129,18 @@ export function PlotLegendSettings({
111129
style={{ backgroundColor: entry.color }}
112130
aria-hidden
113131
/>
114-
<span className="min-w-0 flex-1 truncate text-[11px] leading-tight text-foreground" title={entry.label}>
132+
<span
133+
className="min-w-0 flex-1 truncate text-[11px] leading-tight text-foreground"
134+
title={entry.label}
135+
>
115136
{entry.label}
116137
</span>
117138
</label>
118139
);
119140
})}
120141
</div>
121142
</ScrollArea>
122-
</>
123-
)}
124-
</SettingsSection>
143+
</>
144+
</div>
125145
);
126146
}

src/features/panels/Plot/PlotPanel.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ import { usePlotPanelData } from './usePlotPanelData';
3131
import { usePlotTopicDetection } from './usePlotTopicDetection';
3232
import { timeToSec } from '@/core/analysis/timeSeries';
3333

34+
/** Stable empty array for activeTopics when no topics are configured. */
35+
const EMPTY_TOPICS: string[] = [];
36+
3437
interface PlotPanelProps {
3538
player: Player;
3639
panelId: string;
@@ -87,10 +90,14 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
8790

8891
const plottableTopics = useMemo(() => filterPlottableTopics(topics), [topics]);
8992
const topicByName = useMemo(() => buildTopicByName(topics), [topics]);
90-
const activeTopics = useMemo(
91-
() => selectActivePlotTopics(config, topicByName),
93+
const activeTopicsKey = useMemo(
94+
() => selectActivePlotTopics(config, topicByName).join('\n'),
9295
[config, topicByName],
9396
);
97+
const activeTopics = useMemo(
98+
() => (activeTopicsKey === '' ? EMPTY_TOPICS : activeTopicsKey.split('\n')),
99+
[activeTopicsKey],
100+
);
94101
const hasPlotPaths = useMemo(() => hasConfiguredPlotPaths(config), [config]);
95102
const hasEnabledSeries = useMemo(() => hasEnabledPlotPaths(config), [config]);
96103
const primary = selectPrimarySeries(config);
@@ -216,7 +223,10 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
216223
const handleChartClick = (event: React.MouseEvent<HTMLDivElement>) => {
217224
const chart = uplotRef.current;
218225
if (!chart || config.xAxisMode !== 'timestamp') return;
219-
const rect = chart.root.getBoundingClientRect();
226+
// posToVal expects a position relative to the plotting area (the `over`
227+
// element), which is what hover/cursor uses. Using `root` here would add the
228+
// left Y-axis width as a fixed offset, shifting the seek target to the right.
229+
const rect = chart.over.getBoundingClientRect();
220230
const x = chart.posToVal(event.clientX - rect.left, 'x');
221231
if (Number.isFinite(x)) {
222232
player.seek(secToTime(x));

src/features/panels/Plot/PlotPanelSettings.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ export function PlotPanelSettings({
142142

143143
return (
144144
<div className="space-y-2">
145-
<PlotLegendSettings panelId={panelId} config={config} setConfig={setConfig} />
146145
<SettingsSection title={formatMessage({ id: 'panels.plot.settings.section.plot' })}>
147146
<SettingsField label={formatMessage({ id: 'panels.plot.settings.field.xAxis' })}>
148147
<SettingsSelect<PlotXAxisMode>
@@ -353,6 +352,12 @@ export function PlotPanelSettings({
353352
}
354353
/>
355354
</SettingsField>
355+
<PlotLegendSettings
356+
panelId={panelId}
357+
seriesId={series.id}
358+
config={config}
359+
setConfig={setConfig}
360+
/>
356361
</div>
357362
))}
358363
<button

src/features/panels/Plot/plotConfigSelectors.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
plotDataConfigKey,
77
plotEnabledSeriesIds,
88
plotEnabledSeriesKey,
9+
plotSeriesVisualKey,
910
selectActivePlotTopics,
1011
} from './plotConfigSelectors';
1112
import type { TopicInfo } from '@/core/types/ros';
@@ -64,6 +65,52 @@ describe('plotEnabledSeriesKey / plotEnabledSeriesIds', () => {
6465
});
6566
});
6667

68+
describe('plotSeriesVisualKey', () => {
69+
it('changes when line style or size changes but data key does not', () => {
70+
const base = defaultPlotConfig();
71+
const a = {
72+
...base,
73+
series: [createPlotSeries({ id: 's1', topic: '/a', path: 'data', lineStyle: 'solid', lineSize: 1.5 })],
74+
};
75+
const b = { ...a, series: [{ ...a.series[0], lineStyle: 'dashed' as const }] };
76+
const c = { ...a, series: [{ ...a.series[0], lineSize: 3 }] };
77+
expect(plotSeriesVisualKey(a)).not.toBe(plotSeriesVisualKey(b));
78+
expect(plotSeriesVisualKey(a)).not.toBe(plotSeriesVisualKey(c));
79+
// Visual-only changes must never trigger a range re-read.
80+
expect(plotDataConfigKey(a)).toBe(plotDataConfigKey(b));
81+
expect(plotDataConfigKey(a)).toBe(plotDataConfigKey(c));
82+
});
83+
84+
it('changes when color or label changes', () => {
85+
const base = defaultPlotConfig();
86+
const a = {
87+
...base,
88+
series: [createPlotSeries({ id: 's1', topic: '/a', path: 'data', color: '#111', label: 'A' })],
89+
};
90+
const b = { ...a, series: [{ ...a.series[0], color: '#222' }] };
91+
const c = { ...a, series: [{ ...a.series[0], label: 'B' }] };
92+
expect(plotSeriesVisualKey(a)).not.toBe(plotSeriesVisualKey(b));
93+
expect(plotSeriesVisualKey(a)).not.toBe(plotSeriesVisualKey(c));
94+
});
95+
});
96+
97+
describe('legend visibility vs range read', () => {
98+
it('does not change active topics key or plotDataConfigKey when hiddenLegendKeys toggles', () => {
99+
const base = defaultPlotConfig();
100+
const config = {
101+
...base,
102+
series: [
103+
createPlotSeries({ id: 's1', topic: '/a', path: 'data', enabled: true }),
104+
createPlotSeries({ id: 's2', topic: '/b', path: 'data', enabled: true }),
105+
],
106+
};
107+
const hidden = { ...config, hiddenLegendKeys: ['s1'] };
108+
const topicsKey = (c: typeof config) => selectActivePlotTopics(c, topicByName).join('\n');
109+
expect(topicsKey(config)).toBe(topicsKey(hidden));
110+
expect(plotDataConfigKey(config)).toBe(plotDataConfigKey(hidden));
111+
});
112+
});
113+
67114
describe('selectActivePlotTopics', () => {
68115
it('includes topics for disabled series too (so toggling does not refetch)', () => {
69116
const base = defaultPlotConfig();

src/features/panels/Plot/plotConfigSelectors.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ export function plotEnabledSeriesIds(config: PlotConfig): Set<string> {
9797
return new Set(config.series.filter((s) => s.enabled).map((s) => s.id));
9898
}
9999

100+
/**
101+
* Stable key for per-series visual config (label/color/line style/size).
102+
*
103+
* Changing these should re-render the dataset visuals (a cheap rebuild) but
104+
* never trigger a range re-read — that is why it is tracked separately from
105+
* {@link plotDataConfigKey}.
106+
*/
107+
export function plotSeriesVisualKey(config: PlotConfig): string {
108+
return config.series
109+
.map((s) => `${s.id}|${s.label}|${s.color}|${s.lineStyle}|${s.lineSize}`)
110+
.join(';');
111+
}
112+
100113
/** Stable key for uPlot series topology (rebuild when this changes). */
101114
export function plotChartTopologyKey(dataset: {
102115
series: ReadonlyArray<{

src/features/panels/Plot/plotDatasetAccumulator.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,4 +161,42 @@ describe('PlotDatasetAccumulator', () => {
161161
const onlyS2 = accumulator.buildDataset(new Set(['s2']));
162162
expect(onlyS2.series.map((s) => s.key.startsWith('s2:') ? 's2' : 's1')).toEqual(['s2']);
163163
});
164+
165+
it('applies live visual config (line style/size/color) at build time without re-ingest', () => {
166+
const baseSeries = defaultPlotConfig().series[0];
167+
const config = {
168+
...defaultPlotConfig(),
169+
downsampleMode: 'none' as const,
170+
series: [
171+
{
172+
...baseSeries,
173+
id: 's1',
174+
topic: '/a',
175+
path: 'data',
176+
timestampMode: 'receiveTime' as const,
177+
lineStyle: 'solid' as const,
178+
lineSize: 1.5,
179+
color: '#111',
180+
},
181+
],
182+
};
183+
const events = [
184+
event('/a', 1, { data: 1 }, 'std_msgs/msg/Float64'),
185+
event('/a', 2, { data: 2 }, 'std_msgs/msg/Float64'),
186+
];
187+
188+
const accumulator = new PlotDatasetAccumulator(config);
189+
accumulator.append(events);
190+
191+
// Build with a live config carrying updated visuals (no new data appended).
192+
const liveConfig = {
193+
...config,
194+
series: [{ ...config.series[0], lineStyle: 'dashed' as const, lineSize: 4, color: '#abcdef' }],
195+
};
196+
const dataset = accumulator.buildDataset(new Set(['s1']), liveConfig);
197+
198+
expect(dataset.series[0].lineStyle).toBe('dashed');
199+
expect(dataset.series[0].lineSize).toBe(4);
200+
expect(dataset.series[0].color).toBe('#abcdef');
201+
});
164202
});

0 commit comments

Comments
 (0)