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
33 changes: 33 additions & 0 deletions src/features/panels/Plot/PlotChartLegend.test.ts
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
232 changes: 232 additions & 0 deletions src/features/panels/Plot/PlotChartLegend.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-0.5 ${visible ? '' : 'opacity-55'}`}>
<button
type="button"
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-accent"
onClick={onToggle}
title={formatMessage({
id: visible ? 'panels.plot.legend.hideCurve' : 'panels.plot.legend.showCurve',
})}
aria-label={formatMessage({
id: visible ? 'panels.plot.legend.hideCurve' : 'panels.plot.legend.showCurve',
})}
>
{visible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
<span
className="inline-block h-2 w-2 shrink-0 rounded-sm ring-1 ring-border/60"
style={{ backgroundColor: entry.color }}
aria-hidden
/>
<button
type="button"
className="min-w-0 flex-1 truncate text-left text-[11px] leading-tight text-foreground hover:underline"
onClick={onToggle}
title={entry.label}
>
{entry.label}
</button>
{showOnlyAction && onOnly && (
<button
type="button"
className="shrink-0 rounded px-1 text-[10px] text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={onOnly}
title={formatMessage({ id: 'panels.plot.legend.onlyThis' })}
>
{formatMessage({ id: 'panels.plot.legend.only' })}
</button>
)}
{!showOnlyAction && (
<span className="invisible shrink-0 rounded px-1 text-[10px]" aria-hidden>
{formatMessage({ id: 'panels.plot.legend.only' })}
</span>
)}
</div>
);
}

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 (
<div
className={`absolute left-2 top-2 z-20 w-80 max-w-[min(28rem,70%)] rounded border border-border bg-card/80 text-foreground opacity-65 shadow-sm backdrop-blur transition-opacity hover:bg-card/95 hover:opacity-100 focus-within:bg-card/95 focus-within:opacity-100 ${
expanded ? 'bottom-2 flex flex-col' : ''
}`}
onClick={stopPanelInteraction}
onDoubleClick={stopPanelInteraction}
onPointerDown={stopPanelInteraction}
onWheel={stopPanelInteraction}
>
<div className="flex items-center gap-2 border-b border-border/70 px-2 py-1">
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">
{formatMessage(
{ id: 'panels.plot.legend.visibleCount' },
{ visible: visibleCount, total: entries.length },
)}
</span>
<button
type="button"
className="inline-flex h-5 items-center gap-1 rounded px-1 text-[10px] text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => setExpanded((value) => !value)}
title={formatMessage({
id: expanded ? 'panels.plot.legend.collapse' : 'panels.plot.legend.expand',
})}
aria-label={formatMessage({
id: expanded ? 'panels.plot.legend.collapse' : 'panels.plot.legend.expand',
})}
>
{entries.length > COLLAPSED_LIMIT && !expanded ? `+${entries.length - COLLAPSED_LIMIT}` : null}
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
</div>

{expanded ? (
<>
<div className="space-y-1 border-b border-border/70 p-2">
<input
value={query}
onChange={(event) => 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' })}
/>
<div className="flex items-center gap-1">
<button
type="button"
className="rounded border border-border px-1.5 py-0.5 text-[10px] hover:bg-accent"
onClick={() => setHiddenKeys(setPlotLegendGroupVisible(hiddenKeys, allKeys, true))}
>
{formatMessage({ id: 'panels.plot.legend.showAll' })}
</button>
<button
type="button"
className="rounded border border-border px-1.5 py-0.5 text-[10px] hover:bg-accent"
onClick={() => setHiddenKeys(setPlotLegendGroupVisible(hiddenKeys, allKeys, false))}
>
{formatMessage({ id: 'panels.plot.legend.hideAll' })}
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{filtered.length === 0 ? (
<div className="px-2 py-2 text-[11px] text-muted-foreground">
{formatMessage({ id: 'panels.plot.legend.noMatches' })}
</div>
) : (
filtered.map((entry) => (
<LegendRow
key={entry.key}
entry={entry}
visible={isPlotLegendVisible(hiddenKeys, entry.key)}
onToggle={() => toggleEntry(entry)}
onOnly={() => showOnly(entry)}
showOnlyAction
/>
))
)}
</div>
</>
) : (
<div className="p-1">
{collapsedEntries.map((entry) => (
<LegendRow
key={entry.key}
entry={entry}
visible={isPlotLegendVisible(hiddenKeys, entry.key)}
onToggle={() => toggleEntry(entry)}
/>
))}
</div>
)}
</div>
);
}
3 changes: 2 additions & 1 deletion src/features/panels/Plot/PlotLegendSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
plotLegendSelectionState,
setPlotLegendGroupVisible,
setPlotLegendVisible,
visiblePlotLegendCount,
} from './plotLegendVisibility';
import { usePlotLegendEntries } from './plotPanelRuntimeStore';

Expand Down Expand Up @@ -48,7 +49,7 @@ export function PlotLegendSettings({
);

const visibleCount = useMemo(
() => entries.filter((entry) => isPlotLegendVisible(hiddenKeys, entry.key)).length,
() => visiblePlotLegendCount(entries, hiddenKeys),
[entries, hiddenKeys],
);

Expand Down
42 changes: 39 additions & 3 deletions src/features/panels/Plot/PlotPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -78,6 +80,8 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
const { formatMessage } = useIntl();
const containerRef = useRef<HTMLDivElement | null>(null);
const autoTopicAppliedRef = useRef(false);
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const [viewportState, setViewportState] = useState<PlotViewportState>({ x: false, y: false });

const { startTime, endTime, randomAccessByTopic, topics } = useMessagePipeline(
useShallow((state: MessagePipelineState) => ({
Expand Down Expand Up @@ -132,7 +136,11 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ 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,
Expand All @@ -142,6 +150,7 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
xRange,
logStart: startTime,
loading,
onViewportStateChange: handleViewportStateChange,
});

useEffect(() => {
Expand Down Expand Up @@ -221,6 +230,13 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
: null;

const handleChartClick = (event: React.MouseEvent<HTMLDivElement>) => {
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`
Expand All @@ -236,6 +252,7 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ 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 (
<div className="flex h-full min-h-0 flex-col bg-background">
Expand Down Expand Up @@ -277,8 +294,27 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
<div
ref={containerRef}
className="min-h-0 flex-1 w-full overflow-hidden"
onPointerDown={(event) => {
pointerDownRef.current = { x: event.clientX, y: event.clientY };
}}
onClick={handleChartClick}
/>
<PlotChartLegend panelId={panelId} config={config} setConfig={setConfig} />
{showResetZoom && (
<button
type="button"
className="absolute right-2 top-2 z-20 inline-flex h-7 items-center gap-1 rounded border border-border bg-card/95 px-2 text-[11px] text-foreground shadow-sm hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
resetViewport();
}}
title={formatMessage({ id: 'panels.plot.toolbar.resetZoom' })}
aria-label={formatMessage({ id: 'panels.plot.toolbar.resetZoomAria' })}
>
<RotateCcw className="h-3.5 w-3.5" />
{formatMessage({ id: 'panels.plot.toolbar.resetZoom' })}
</button>
)}
{!hasSeries && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-muted-foreground">
{formatMessage({ id: 'panels.plot.empty.selectTopic' })}
Expand Down
Loading
Loading