Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ioai/rosview",
"version": "1.5.0",
"version": "1.5.1",
"description": "High-performance robotics data visualization for MCAP, ROS bag, ROS2 db3, HDF5 and BVH — embeddable React component and standalone SPA",
"keywords": [
"ros",
Expand Down
68 changes: 44 additions & 24 deletions src/features/panels/Plot/PlotLegendSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,46 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useIntl } from 'react-intl';
import { SettingsSection } from '../framework/settings/SettingsPrimitives';
import { ScrollArea } from '@/shared/ui/scroll-area';
import type { PlotConfig } from './defaults';
import {
isPlotLegendVisible,
plotLegendSelectionState,
setAllPlotLegendVisible,
setPlotLegendGroupVisible,
setPlotLegendVisible,
} from './plotLegendVisibility';
import { usePlotLegendEntries } from './plotPanelRuntimeStore';

interface PlotLegendSettingsProps {
panelId: string;
seriesId: string;
config: PlotConfig;
setConfig: (next: PlotConfig | ((prev: PlotConfig) => PlotConfig)) => void;
}

function legendInputId(panelId: string, index: number): string {
return `plot-legend-${panelId}-${index}`;
function legendKeyPrefix(seriesId: string): string {
return `${seriesId}:`;
}

function legendInputId(panelId: string, seriesId: string, index: number): string {
return `plot-legend-${panelId}-${seriesId}-${index}`;
}

export function PlotLegendSettings({
panelId,
seriesId,
config,
setConfig,
}: PlotLegendSettingsProps): React.ReactNode {
const { formatMessage } = useIntl();
const entries = usePlotLegendEntries(panelId);
const allEntries = usePlotLegendEntries(panelId);
const prefix = legendKeyPrefix(seriesId);
const entries = useMemo(
() => allEntries.filter((entry) => entry.key.startsWith(prefix)),
[allEntries, prefix],
);
const hiddenKeys = config.hiddenLegendKeys;
const selectAllRef = useRef<HTMLInputElement>(null);
const selectAllId = `plot-legend-${panelId}-all`;
const selectAllId = `plot-legend-${panelId}-${seriesId}-all`;

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

if (entries.length <= 1) {
return null;
}

const setHiddenKeys = (next: string[]) => {
setConfig((prev) => ({ ...prev, hiddenLegendKeys: next }));
};
Expand All @@ -57,21 +71,25 @@ export function PlotLegendSettings({
};

const toggleAll = (visible: boolean) => {
setHiddenKeys(setAllPlotLegendVisible(entries.map((entry) => entry.key), visible));
setHiddenKeys(
setPlotLegendGroupVisible(
hiddenKeys,
entries.map((entry) => entry.key),
visible,
),
);
};

return (
<SettingsSection
title={formatMessage({ id: 'panels.plot.settings.section.legend' })}
description={formatMessage({ id: 'panels.plot.settings.legend.description' })}
>
{entries.length === 0 ? (
<p className="px-1 text-[11px] text-muted-foreground">
{formatMessage({ id: 'panels.plot.settings.legend.empty' })}
</p>
) : (
<>
<div className="mb-1.5 flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
<div className="space-y-1 border-t border-border pt-2">
<p className="text-[10px] font-medium text-muted-foreground">
{formatMessage({ id: 'panels.plot.settings.series.legend.title' })}
</p>
<p className="px-0.5 text-[10px] text-muted-foreground">
{formatMessage({ id: 'panels.plot.settings.legend.description' })}
</p>
<>
<div className="flex items-center gap-2 rounded border border-border bg-muted/30 px-2 py-1">
<input
ref={selectAllRef}
id={selectAllId}
Expand All @@ -88,10 +106,10 @@ export function PlotLegendSettings({
)}
</label>
</div>
<ScrollArea className="max-h-72 rounded border border-border">
<ScrollArea className="max-h-48 rounded border border-border">
<div className="flex flex-col py-0.5">
{entries.map((entry, index) => {
const inputId = legendInputId(panelId, index);
const inputId = legendInputId(panelId, seriesId, index);
const visible = isPlotLegendVisible(hiddenKeys, entry.key);
return (
<label
Expand All @@ -111,16 +129,18 @@ export function PlotLegendSettings({
style={{ backgroundColor: entry.color }}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-[11px] leading-tight text-foreground" title={entry.label}>
<span
className="min-w-0 flex-1 truncate text-[11px] leading-tight text-foreground"
title={entry.label}
>
{entry.label}
</span>
</label>
);
})}
</div>
</ScrollArea>
</>
)}
</SettingsSection>
</>
</div>
);
}
16 changes: 13 additions & 3 deletions src/features/panels/Plot/PlotPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import { usePlotPanelData } from './usePlotPanelData';
import { usePlotTopicDetection } from './usePlotTopicDetection';
import { timeToSec } from '@/core/analysis/timeSeries';

/** Stable empty array for activeTopics when no topics are configured. */
const EMPTY_TOPICS: string[] = [];

interface PlotPanelProps {
player: Player;
panelId: string;
Expand Down Expand Up @@ -87,10 +90,14 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s

const plottableTopics = useMemo(() => filterPlottableTopics(topics), [topics]);
const topicByName = useMemo(() => buildTopicByName(topics), [topics]);
const activeTopics = useMemo(
() => selectActivePlotTopics(config, topicByName),
const activeTopicsKey = useMemo(
() => selectActivePlotTopics(config, topicByName).join('\n'),
[config, topicByName],
);
const activeTopics = useMemo(
() => (activeTopicsKey === '' ? EMPTY_TOPICS : activeTopicsKey.split('\n')),
[activeTopicsKey],
);
const hasPlotPaths = useMemo(() => hasConfiguredPlotPaths(config), [config]);
const hasEnabledSeries = useMemo(() => hasEnabledPlotPaths(config), [config]);
const primary = selectPrimarySeries(config);
Expand Down Expand Up @@ -216,7 +223,10 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
const handleChartClick = (event: React.MouseEvent<HTMLDivElement>) => {
const chart = uplotRef.current;
if (!chart || config.xAxisMode !== 'timestamp') return;
const rect = chart.root.getBoundingClientRect();
// posToVal expects a position relative to the plotting area (the `over`
// element), which is what hover/cursor uses. Using `root` here would add the
// left Y-axis width as a fixed offset, shifting the seek target to the right.
const rect = chart.over.getBoundingClientRect();
const x = chart.posToVal(event.clientX - rect.left, 'x');
if (Number.isFinite(x)) {
player.seek(secToTime(x));
Expand Down
7 changes: 6 additions & 1 deletion src/features/panels/Plot/PlotPanelSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,6 @@ export function PlotPanelSettings({

return (
<div className="space-y-2">
<PlotLegendSettings panelId={panelId} config={config} setConfig={setConfig} />
<SettingsSection title={formatMessage({ id: 'panels.plot.settings.section.plot' })}>
<SettingsField label={formatMessage({ id: 'panels.plot.settings.field.xAxis' })}>
<SettingsSelect<PlotXAxisMode>
Expand Down Expand Up @@ -353,6 +352,12 @@ export function PlotPanelSettings({
}
/>
</SettingsField>
<PlotLegendSettings
panelId={panelId}
seriesId={series.id}
config={config}
setConfig={setConfig}
/>
</div>
))}
<button
Expand Down
47 changes: 47 additions & 0 deletions src/features/panels/Plot/plotConfigSelectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
plotDataConfigKey,
plotEnabledSeriesIds,
plotEnabledSeriesKey,
plotSeriesVisualKey,
selectActivePlotTopics,
} from './plotConfigSelectors';
import type { TopicInfo } from '@/core/types/ros';
Expand Down Expand Up @@ -64,6 +65,52 @@ describe('plotEnabledSeriesKey / plotEnabledSeriesIds', () => {
});
});

describe('plotSeriesVisualKey', () => {
it('changes when line style or size changes but data key does not', () => {
const base = defaultPlotConfig();
const a = {
...base,
series: [createPlotSeries({ id: 's1', topic: '/a', path: 'data', lineStyle: 'solid', lineSize: 1.5 })],
};
const b = { ...a, series: [{ ...a.series[0], lineStyle: 'dashed' as const }] };
const c = { ...a, series: [{ ...a.series[0], lineSize: 3 }] };
expect(plotSeriesVisualKey(a)).not.toBe(plotSeriesVisualKey(b));
expect(plotSeriesVisualKey(a)).not.toBe(plotSeriesVisualKey(c));
// Visual-only changes must never trigger a range re-read.
expect(plotDataConfigKey(a)).toBe(plotDataConfigKey(b));
expect(plotDataConfigKey(a)).toBe(plotDataConfigKey(c));
});

it('changes when color or label changes', () => {
const base = defaultPlotConfig();
const a = {
...base,
series: [createPlotSeries({ id: 's1', topic: '/a', path: 'data', color: '#111', label: 'A' })],
};
const b = { ...a, series: [{ ...a.series[0], color: '#222' }] };
const c = { ...a, series: [{ ...a.series[0], label: 'B' }] };
expect(plotSeriesVisualKey(a)).not.toBe(plotSeriesVisualKey(b));
expect(plotSeriesVisualKey(a)).not.toBe(plotSeriesVisualKey(c));
});
});

describe('legend visibility vs range read', () => {
it('does not change active topics key or plotDataConfigKey when hiddenLegendKeys toggles', () => {
const base = defaultPlotConfig();
const config = {
...base,
series: [
createPlotSeries({ id: 's1', topic: '/a', path: 'data', enabled: true }),
createPlotSeries({ id: 's2', topic: '/b', path: 'data', enabled: true }),
],
};
const hidden = { ...config, hiddenLegendKeys: ['s1'] };
const topicsKey = (c: typeof config) => selectActivePlotTopics(c, topicByName).join('\n');
expect(topicsKey(config)).toBe(topicsKey(hidden));
expect(plotDataConfigKey(config)).toBe(plotDataConfigKey(hidden));
});
});

describe('selectActivePlotTopics', () => {
it('includes topics for disabled series too (so toggling does not refetch)', () => {
const base = defaultPlotConfig();
Expand Down
13 changes: 13 additions & 0 deletions src/features/panels/Plot/plotConfigSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ export function plotEnabledSeriesIds(config: PlotConfig): Set<string> {
return new Set(config.series.filter((s) => s.enabled).map((s) => s.id));
}

/**
* Stable key for per-series visual config (label/color/line style/size).
*
* Changing these should re-render the dataset visuals (a cheap rebuild) but
* never trigger a range re-read — that is why it is tracked separately from
* {@link plotDataConfigKey}.
*/
export function plotSeriesVisualKey(config: PlotConfig): string {
return config.series
.map((s) => `${s.id}|${s.label}|${s.color}|${s.lineStyle}|${s.lineSize}`)
.join(';');
}

/** Stable key for uPlot series topology (rebuild when this changes). */
export function plotChartTopologyKey(dataset: {
series: ReadonlyArray<{
Expand Down
38 changes: 38 additions & 0 deletions src/features/panels/Plot/plotDatasetAccumulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,42 @@ describe('PlotDatasetAccumulator', () => {
const onlyS2 = accumulator.buildDataset(new Set(['s2']));
expect(onlyS2.series.map((s) => s.key.startsWith('s2:') ? 's2' : 's1')).toEqual(['s2']);
});

it('applies live visual config (line style/size/color) at build time without re-ingest', () => {
const baseSeries = defaultPlotConfig().series[0];
const config = {
...defaultPlotConfig(),
downsampleMode: 'none' as const,
series: [
{
...baseSeries,
id: 's1',
topic: '/a',
path: 'data',
timestampMode: 'receiveTime' as const,
lineStyle: 'solid' as const,
lineSize: 1.5,
color: '#111',
},
],
};
const events = [
event('/a', 1, { data: 1 }, 'std_msgs/msg/Float64'),
event('/a', 2, { data: 2 }, 'std_msgs/msg/Float64'),
];

const accumulator = new PlotDatasetAccumulator(config);
accumulator.append(events);

// Build with a live config carrying updated visuals (no new data appended).
const liveConfig = {
...config,
series: [{ ...config.series[0], lineStyle: 'dashed' as const, lineSize: 4, color: '#abcdef' }],
};
const dataset = accumulator.buildDataset(new Set(['s1']), liveConfig);

expect(dataset.series[0].lineStyle).toBe('dashed');
expect(dataset.series[0].lineSize).toBe(4);
expect(dataset.series[0].color).toBe('#abcdef');
});
});
Loading
Loading