From 734d5dd01d800e57b1b77402ff0a6727c79ef99f Mon Sep 17 00:00:00 2001 From: joaner Date: Thu, 4 Jun 2026 12:00:25 +0800 Subject: [PATCH 1/3] 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. --- src/features/panels/Plot/PlotPanel.tsx | 16 ++++-- .../panels/Plot/plotConfigSelectors.test.ts | 47 ++++++++++++++++ .../panels/Plot/plotConfigSelectors.ts | 13 +++++ .../Plot/plotDatasetAccumulator.test.ts | 38 +++++++++++++ .../panels/Plot/plotDatasetAccumulator.ts | 19 +++++-- .../panels/Plot/plotPointCollector.ts | 21 ++++++++ src/features/panels/Plot/usePlotChart.test.ts | 14 +++-- src/features/panels/Plot/usePlotChart.ts | 53 ++++++++++++++++--- src/features/panels/Plot/usePlotPanelData.ts | 21 +++++++- 9 files changed, 223 insertions(+), 19 deletions(-) diff --git a/src/features/panels/Plot/PlotPanel.tsx b/src/features/panels/Plot/PlotPanel.tsx index aba5931..e90f710 100644 --- a/src/features/panels/Plot/PlotPanel.tsx +++ b/src/features/panels/Plot/PlotPanel.tsx @@ -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; @@ -87,10 +90,14 @@ export const PlotPanel: React.FC = ({ 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); @@ -216,7 +223,10 @@ export const PlotPanel: React.FC = ({ player, panelId, config, s const handleChartClick = (event: React.MouseEvent) => { 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)); diff --git a/src/features/panels/Plot/plotConfigSelectors.test.ts b/src/features/panels/Plot/plotConfigSelectors.test.ts index 8b6ba40..ba9e245 100644 --- a/src/features/panels/Plot/plotConfigSelectors.test.ts +++ b/src/features/panels/Plot/plotConfigSelectors.test.ts @@ -6,6 +6,7 @@ import { plotDataConfigKey, plotEnabledSeriesIds, plotEnabledSeriesKey, + plotSeriesVisualKey, selectActivePlotTopics, } from './plotConfigSelectors'; import type { TopicInfo } from '@/core/types/ros'; @@ -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(); diff --git a/src/features/panels/Plot/plotConfigSelectors.ts b/src/features/panels/Plot/plotConfigSelectors.ts index 03eea08..553d45a 100644 --- a/src/features/panels/Plot/plotConfigSelectors.ts +++ b/src/features/panels/Plot/plotConfigSelectors.ts @@ -97,6 +97,19 @@ export function plotEnabledSeriesIds(config: PlotConfig): Set { 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<{ diff --git a/src/features/panels/Plot/plotDatasetAccumulator.test.ts b/src/features/panels/Plot/plotDatasetAccumulator.test.ts index 96793fa..61e8387 100644 --- a/src/features/panels/Plot/plotDatasetAccumulator.test.ts +++ b/src/features/panels/Plot/plotDatasetAccumulator.test.ts @@ -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'); + }); }); diff --git a/src/features/panels/Plot/plotDatasetAccumulator.ts b/src/features/panels/Plot/plotDatasetAccumulator.ts index b81f6ba..258ad42 100644 --- a/src/features/panels/Plot/plotDatasetAccumulator.ts +++ b/src/features/panels/Plot/plotDatasetAccumulator.ts @@ -6,6 +6,7 @@ import type { MessageEvent } from '@/core/types/ros'; import type { BuildPlotDatasetOptions, PlotDataset, PointBucket } from './types'; import { alignBuckets } from './plotAlign'; import { + applySeriesConfigVisuals, assignBucketColors, collectCustomPoints, collectIndexPoints, @@ -46,6 +47,7 @@ function datasetFromBuckets( options: PlotDatasetAccumulatorOptions, warnings: PlotDatasetWarning[], enabledSeriesIds?: ReadonlySet, + visualConfig?: PlotConfig, ): PlotDataset { // Filter buckets to those whose owning series is currently enabled. // The accumulator deliberately ingests buckets for *all configured* series @@ -60,6 +62,10 @@ function datasetFromBuckets( ), ); + // Apply live visuals before palette assignment so user color overrides on + // single-bucket series are respected while multi-bucket series still get + // palette colors. + applySeriesConfigVisuals(filteredBuckets.values(), visualConfig ?? config); assignBucketColors(filteredBuckets); const seriesBuckets = [...filteredBuckets.values()]; const shouldDownsample = options.forceDownsample === true || config.downsampleMode === 'minMaxLast'; @@ -115,7 +121,13 @@ export class PlotDatasetAccumulator { * buckets owned by those series are emitted — letting callers re-render * after visibility toggles without re-ingesting data. */ - buildDataset(enabledSeriesIds?: ReadonlySet): PlotDataset { + buildDataset( + enabledSeriesIds?: ReadonlySet, + visualConfig?: PlotConfig, + ): PlotDataset { + // Live config used only to overlay current visuals (line style/size/color). + // Structure (paths, axis mode, ...) still comes from the ingest-time config. + const visuals = visualConfig ?? this._config; if (this._messageCount === 0) { return { ...EMPTY_DATASET, @@ -133,7 +145,7 @@ export class PlotDatasetAccumulator { // Re-collect at build time so we can apply the live enabled filter. const filteredConfig = this._configWithSeriesFilter(seriesFilter); const buckets = collectIndexPoints([...this._latestByTopic.values()], filteredConfig); - return datasetFromBuckets(buckets, filteredConfig, this._options, warnings); + return datasetFromBuckets(buckets, filteredConfig, this._options, warnings, undefined, visuals); } if (this._config.xAxisMode === 'currentCustom') { @@ -145,7 +157,7 @@ export class PlotDatasetAccumulator { true, warnings, ); - return datasetFromBuckets(buckets, filteredConfig, this._options, warnings); + return datasetFromBuckets(buckets, filteredConfig, this._options, warnings, undefined, visuals); } const warnings = Array.from(this._warnings.values()); @@ -163,6 +175,7 @@ export class PlotDatasetAccumulator { this._options, warnings, new Set(this._config.series.filter(seriesFilter).map((s) => s.id)), + visuals, ); } diff --git a/src/features/panels/Plot/plotPointCollector.ts b/src/features/panels/Plot/plotPointCollector.ts index 4207187..dad8fe6 100644 --- a/src/features/panels/Plot/plotPointCollector.ts +++ b/src/features/panels/Plot/plotPointCollector.ts @@ -81,6 +81,27 @@ export function pushPoint( bucket.points.push({ x, y }); } +/** + * Overlay live config visuals (line style/size/color) onto already-accumulated + * buckets. The accumulator captures visuals at ingest time, so without this + * a later config change (e.g. dashed -> solid, thicker line) would not surface + * until a full re-read. Color is applied before {@link assignBucketColors} so + * the palette still wins for multi-bucket series (e.g. JointState arrays). + */ +export function applySeriesConfigVisuals( + buckets: Iterable, + config: PlotConfig, +): void { + const seriesById = new Map(config.series.map((series) => [series.id, series])); + for (const bucket of buckets) { + const series = seriesById.get(bucket.seriesConfigId); + if (!series) continue; + bucket.series.lineStyle = series.lineStyle; + bucket.series.lineSize = series.lineSize; + if (series.color) bucket.series.color = series.color; + } +} + export function assignBucketColors(buckets: Map): void { const bucketsBySeries = new Map(); for (const bucket of buckets.values()) { diff --git a/src/features/panels/Plot/usePlotChart.test.ts b/src/features/panels/Plot/usePlotChart.test.ts index eaafc7b..382e21e 100644 --- a/src/features/panels/Plot/usePlotChart.test.ts +++ b/src/features/panels/Plot/usePlotChart.test.ts @@ -40,10 +40,16 @@ describe('diffSeriesTopology', () => { expect(diffSeriesTopology(a, b)).toEqual({ kind: 'remount' }); }); - it('falls back to remount on meta change for same key', () => { - const a = [sig('a', 'l|#000|solid|1')]; - const b = [sig('a', 'l|#fff|solid|1')]; - expect(diffSeriesTopology(a, b)).toEqual({ kind: 'remount' }); + it('returns styleUpdate on meta change for same keys (no remount)', () => { + const a = [sig('a', 'l|#000|solid|1'), sig('b', 'l|#111|solid|1')]; + const b = [sig('a', 'l|#fff|solid|1'), sig('b', 'l|#111|dashed|2')]; + expect(diffSeriesTopology(a, b)).toEqual({ kind: 'styleUpdate', changed: [0, 1] }); + }); + + it('reports only the changed indices in styleUpdate', () => { + const a = [sig('a', 'l|#000|solid|1'), sig('b', 'l|#111|solid|1')]; + const b = [sig('a', 'l|#000|solid|1'), sig('b', 'l|#111|solid|3')]; + expect(diffSeriesTopology(a, b)).toEqual({ kind: 'styleUpdate', changed: [1] }); }); it('falls back to remount when prefix differs (mixed insertion)', () => { diff --git a/src/features/panels/Plot/usePlotChart.ts b/src/features/panels/Plot/usePlotChart.ts index d7efc60..bb475c5 100644 --- a/src/features/panels/Plot/usePlotChart.ts +++ b/src/features/panels/Plot/usePlotChart.ts @@ -4,7 +4,7 @@ import { timeToSec } from '@/core/analysis/timeSeries'; import type { Player } from '@/core/types/player'; import type { Time } from '@/core/types/ros'; import { scheduleFrame } from '@/shared/utils/rafScheduler'; -import type { PlotDataset } from './datasets'; +import type { PlotDataset, PlotRuntimeSeries } from './datasets'; import type { PlotConfig } from './defaults'; import { buildPlotSeriesOption, @@ -53,14 +53,16 @@ function seriesSignatures( type DiffResult = | { kind: 'identical' } + | { kind: 'styleUpdate'; changed: number[] } | { kind: 'pureAdd'; added: SeriesSignature[]; addedAt: number } | { kind: 'pureDel'; removedFrom: number; removedCount: number } | { kind: 'remount' }; /** * Cheap topology diff so we can keep uPlot mounted across the most common - * case: new series buckets appearing late during a range read (e.g. a - * JointState topic exposing additional joints). Anything more complex than + * cases: new series buckets appearing late during a range read (e.g. a + * JointState topic exposing additional joints), and pure visual changes + * (line style/size/color) on existing series. Anything more complex than * a clean append/truncate at the tail falls back to a remount. * * Exported for unit testing. @@ -70,14 +72,20 @@ export function diffSeriesTopology( next: SeriesSignature[], ): DiffResult { if (prev.length === next.length) { - let allMatch = true; + let keysMatch = true; + const changed: number[] = []; for (let i = 0; i < prev.length; i++) { - if (prev[i].key !== next[i].key || prev[i].meta !== next[i].meta) { - allMatch = false; + if (prev[i].key !== next[i].key) { + keysMatch = false; break; } + if (prev[i].meta !== next[i].meta) changed.push(i); + } + if (keysMatch) { + // Same series identities & order: either nothing changed, or only + // visual metadata changed (apply in place, no remount). + return changed.length === 0 ? { kind: 'identical' } : { kind: 'styleUpdate', changed }; } - if (allMatch) return { kind: 'identical' }; } // Pure appended series at the tail @@ -126,6 +134,26 @@ export function plotChartYSeriesCount(chart: uPlot): number { return chart.series.length - 1; } +/** Mutable view of a uPlot series including its internal path cache. */ +type MutablePlotSeries = uPlot.Series & { _paths?: unknown }; + +/** + * Apply a series' visual config (color/width/dash/label) onto a live uPlot + * series in place. uPlot's `setSeries` only supports `show`/`focus`, so style + * changes must mutate the series object directly: `width`/`dash` are read each + * draw, `stroke` is re-resolved via `cacheStrokeFill`, and nulling `_paths` + * forces a path rebuild for the new width. The caller must `redraw(true)`. + */ +function applyRuntimeSeriesStyle(chart: uPlot, index: number, series: PlotRuntimeSeries): void { + const target = chart.series[index + 1] as MutablePlotSeries | undefined; + if (!target) return; + target.label = series.label; + target.stroke = () => series.color; + target.width = series.lineSize; + target.dash = series.lineStyle === 'dashed' ? [6, 4] : []; + target._paths = null; +} + /** * Returns true when incremental add/del would be unsafe and the chart should remount. * Exported for unit tests. @@ -265,6 +293,12 @@ export function usePlotChart({ // Remove from the tail; uPlot series indices are 1-based (0 = x-axis). chart.delSeries(diff.removedFrom + 1); } + } else if (diff.kind === 'styleUpdate') { + // Pure visual change (line style/size/color): mutate series in place + // instead of remounting, preserving zoom/pan state. + for (const index of diff.changed) { + applyRuntimeSeriesStyle(chart, index, dataset.series[index]); + } } // Sync show flags for stable indices. @@ -275,6 +309,11 @@ export function usePlotChart({ // setData second arg = false avoids a hard scale reset every batch, which is // what made the chart flash while data was streaming in. chart.setData(dataset.data, false); + // Style mutations are read at draw time, so force one rebuild+redraw to + // surface the new width/dash/stroke immediately. + if (diff.kind === 'styleUpdate') { + chart.redraw(true); + } seriesSignaturesRef.current = nextSignatures; }, [config.followingViewWidthSec, config.xAxisMode, containerRef, dataset, destroyChart, hiddenSeries, mountChart]); diff --git a/src/features/panels/Plot/usePlotPanelData.ts b/src/features/panels/Plot/usePlotPanelData.ts index 0bd9b29..2518c65 100644 --- a/src/features/panels/Plot/usePlotPanelData.ts +++ b/src/features/panels/Plot/usePlotPanelData.ts @@ -8,6 +8,7 @@ import { plotDataConfigKey, plotEnabledSeriesIds, plotEnabledSeriesKey, + plotSeriesVisualKey, } from './plotConfigSelectors'; import { readPlotRangeIncremental, type PlotRangeReadProgress } from './rangeReader'; import type { PlotDatasetWarning } from './plotWarnings'; @@ -60,11 +61,18 @@ export function usePlotPanelData({ const dataConfigKey = useMemo(() => plotDataConfigKey(config), [config]); const enabledSeriesKey = useMemo(() => plotEnabledSeriesKey(config), [config]); + const seriesVisualKey = useMemo(() => plotSeriesVisualKey(config), [config]); const enabledSeriesIds = useMemo(() => plotEnabledSeriesIds(config), [config]); const enabledSeriesIdsRef = useRef(enabledSeriesIds); useEffect(() => { enabledSeriesIdsRef.current = enabledSeriesIds; }, [enabledSeriesIds]); + // Latest config for live visual overlays (line style/size/color) applied at + // dataset build time without re-ingesting data. + const configRef = useRef(config); + useEffect(() => { + configRef.current = config; + }, [config]); const accumulatorRef = useRef(null); const progressRef = useRef(null); @@ -134,7 +142,7 @@ export function usePlotPanelData({ datasetTimeoutRef.current = null; if (controller.signal.aborted) return; lastDatasetFlushMsRef.current = performance.now(); - setDataset(accumulator.buildDataset(enabledSeriesIdsRef.current)); + setDataset(accumulator.buildDataset(enabledSeriesIdsRef.current, configRef.current)); }; const scheduleDatasetFlush = () => { @@ -210,8 +218,17 @@ export function usePlotPanelData({ useEffect(() => { const accumulator = accumulatorRef.current; if (!accumulator) return; - setDataset(accumulator.buildDataset(enabledSeriesIdsRef.current)); + setDataset(accumulator.buildDataset(enabledSeriesIdsRef.current, configRef.current)); }, [enabledSeriesKey]); + // Changing a series' visual config (line style/size/color/label) rebuilds the + // dataset with the live visuals overlaid — a cheap re-render that never + // re-ingests data and lets the chart apply styles incrementally. + useEffect(() => { + const accumulator = accumulatorRef.current; + if (!accumulator) return; + setDataset(accumulator.buildDataset(enabledSeriesIdsRef.current, configRef.current)); + }, [seriesVisualKey]); + return { dataset, loading, progress, error }; } From ce35809b3ddb2c334e0f681a3f26511779f90f50 Mon Sep 17 00:00:00 2001 From: joaner Date: Thu, 4 Jun 2026 12:00:28 +0800 Subject: [PATCH 2/3] chore(release): bump version to 1.5.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d93359..96536ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ioai/rosview", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ioai/rosview", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/package.json b/package.json index f03350e..f794939 100644 --- a/package.json +++ b/package.json @@ -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", From c113693a41f54e737eaae2035c745d66c0e117b7 Mon Sep 17 00:00:00 2001 From: joaner Date: Thu, 4 Jun 2026 12:11:20 +0800 Subject: [PATCH 3/3] 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. --- .../panels/Plot/PlotLegendSettings.tsx | 68 ++++++++++++------- .../panels/Plot/PlotPanelSettings.tsx | 7 +- .../panels/Plot/plotLegendVisibility.test.ts | 10 +++ .../panels/Plot/plotLegendVisibility.ts | 18 +++++ src/shared/intl/messages/en/panels.json | 7 +- src/shared/intl/messages/ja/panels.json | 7 +- src/shared/intl/messages/zh/panels.json | 7 +- 7 files changed, 90 insertions(+), 34 deletions(-) diff --git a/src/features/panels/Plot/PlotLegendSettings.tsx b/src/features/panels/Plot/PlotLegendSettings.tsx index a673f86..9ceeabc 100644 --- a/src/features/panels/Plot/PlotLegendSettings.tsx +++ b/src/features/panels/Plot/PlotLegendSettings.tsx @@ -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(null); - const selectAllId = `plot-legend-${panelId}-all`; + const selectAllId = `plot-legend-${panelId}-${seriesId}-all`; const selection = useMemo( () => plotLegendSelectionState(entries, hiddenKeys), @@ -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 })); }; @@ -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 ( - - {entries.length === 0 ? ( -

- {formatMessage({ id: 'panels.plot.settings.legend.empty' })} -

- ) : ( - <> -
+
+

+ {formatMessage({ id: 'panels.plot.settings.series.legend.title' })} +

+

+ {formatMessage({ id: 'panels.plot.settings.legend.description' })} +

+ <> +
- +
{entries.map((entry, index) => { - const inputId = legendInputId(panelId, index); + const inputId = legendInputId(panelId, seriesId, index); const visible = isPlotLegendVisible(hiddenKeys, entry.key); return ( @@ -119,8 +140,7 @@ export function PlotLegendSettings({ })}
- - )} - + +
); } diff --git a/src/features/panels/Plot/PlotPanelSettings.tsx b/src/features/panels/Plot/PlotPanelSettings.tsx index f5dcefe..a737930 100644 --- a/src/features/panels/Plot/PlotPanelSettings.tsx +++ b/src/features/panels/Plot/PlotPanelSettings.tsx @@ -142,7 +142,6 @@ export function PlotPanelSettings({ return (
- @@ -353,6 +352,12 @@ export function PlotPanelSettings({ } /> +
))}