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
166 changes: 83 additions & 83 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ioai/rosview",
"version": "2.2.0",
"version": "2.3.0",
"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 Expand Up @@ -145,7 +145,7 @@
"typescript": "6.0.3",
"typescript-eslint": "^8.67.0",
"uplot": "^1.6.31",
"vite": "^8.2.1",
"vite": "^8.3.4",
"vitest": "^4.1.11",
"zustand": "^5.0.15"
}
Expand Down
2 changes: 1 addition & 1 deletion src/features/panels/Align/definition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const AlignPanel = lazy(async () => {

export const alignPanelDefinition: PanelDefinition<AlignConfig> = {
type: 'Align',
defaultTitle: 'Align',
defaultTitle: 'Time alignment',
createDefaultConfig: defaultAlignConfig,
configSchema: { version: 1, parse: parseAlignConfig },
render: ({ player, panelId, config, setConfig }) => (
Expand Down
101 changes: 43 additions & 58 deletions src/features/panels/Plot/PlotChartLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { usePlotLegendEntries, type PlotLegendEntry } from './plotPanelRuntimeStore';

const COLLAPSED_LIMIT = 1;
const SEARCH_THRESHOLD = 8;

interface PlotChartLegendProps {
panelId: string;
Expand Down Expand Up @@ -110,15 +111,11 @@ export function PlotChartLegend({
}: PlotChartLegendProps): React.ReactNode {
const { formatMessage } = useIntl();
const entries = usePlotLegendEntries(panelId);
const [expanded, setExpanded] = useState(false);
const [open, setOpen] = useState(true);
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);

Expand All @@ -136,17 +133,18 @@ export function PlotChartLegend({
setHiddenKeys(setOnlyPlotLegendVisible(hiddenKeys, allKeys, entry.key));
};

const showTools = entries.length > SEARCH_THRESHOLD;

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-xs backdrop-blur-sm transition-opacity hover:bg-card/95 hover:opacity-100 focus-within:bg-card/95 focus-within:opacity-100 ${
expanded ? 'bottom-2 flex flex-col' : ''
}`}
className="flex max-h-[45%] shrink-0 flex-col border-t border-border bg-card/60 text-foreground"
data-testid="plot-legend"
onClick={stopPanelInteraction}
onDoubleClick={stopPanelInteraction}
onPointerDown={stopPanelInteraction}
onWheel={stopPanelInteraction}
>
<div className="flex items-center gap-2 border-b border-border/70 px-2 py-1">
<div className="flex shrink-0 items-center gap-2 px-2 py-0.5">
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">
{formatMessage(
{ id: 'panels.plot.legend.visibleCount' },
Expand All @@ -155,48 +153,46 @@ export function PlotChartLegend({
</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',
})}
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>
<button
type="button"
className="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
title={formatMessage({ id: open ? 'panels.plot.legend.collapse' : 'panels.plot.legend.expand' })}
aria-label={formatMessage({ id: open ? '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" />}
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />}
</button>
</div>

{expanded ? (
{open ? (
<>
<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-hidden 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>
{showTools && (
<div className="shrink-0 px-2 pb-1">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
className="h-6 w-full rounded border border-border bg-background px-2 text-[11px] outline-hidden focus:border-primary"
placeholder={formatMessage({ id: 'panels.plot.legend.searchPlaceholder' })}
aria-label={formatMessage({ id: 'panels.plot.legend.searchPlaceholder' })}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
)}
<div
className="min-h-0 flex-1 overflow-y-auto p-1"
style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(9.5rem, 1fr))' }}
>
{filtered.length === 0 ? (
<div className="px-2 py-2 text-[11px] text-muted-foreground">
{formatMessage({ id: 'panels.plot.legend.noMatches' })}
Expand All @@ -215,18 +211,7 @@ export function PlotChartLegend({
)}
</div>
</>
) : (
<div className="p-1">
{collapsedEntries.map((entry) => (
<LegendRow
key={entry.key}
entry={entry}
visible={isPlotLegendVisible(hiddenKeys, entry.key)}
onToggle={() => toggleEntry(entry)}
/>
))}
</div>
)}
) : null}
</div>
);
}
14 changes: 11 additions & 3 deletions src/features/panels/Plot/PlotPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
setPlotLegendEntries,
} from './plotPanelRuntimeStore';
import { PlotChartLegend } from './PlotChartLegend';
import { displaySeriesLabels } from './plotSeriesDisplay';
import { annotatePlotUnits } from './plotUnits';
import { formatPlotDatasetWarning } from './plotWarnings';
import { hasManualPlotViewport, usePlotChart, type PlotViewportState } from './usePlotChart';
import { usePlotPanelData } from './usePlotPanelData';
Expand Down Expand Up @@ -121,7 +123,7 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
endTime,
});

const { dataset, loading, progress, error } = usePlotPanelData({
const { dataset: rawDataset, loading, progress, error } = usePlotPanelData({
player,
config,
activeTopics,
Expand All @@ -131,6 +133,11 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
randomAccessByTopic,
});

const dataset = useMemo(
() => annotatePlotUnits(rawDataset, config.series, topicByName),
[rawDataset, config.series, topicByName],
);

const hiddenSeries = useMemo(
() => hiddenSeriesIndices(dataset.series, config.hiddenLegendKeys),
[config.hiddenLegendKeys, dataset.series],
Expand Down Expand Up @@ -162,9 +169,10 @@ export const PlotPanel: React.FC<PlotPanelProps> = ({ player, panelId, config, s
}, [player, panelId, activeTopics]);

useEffect(() => {
const entries = dataset.series.map((series) => ({
const displayLabels = displaySeriesLabels(dataset.series.map((series) => series.label));
const entries = dataset.series.map((series, index) => ({
key: series.key,
label: series.label,
label: displayLabels[index] ?? series.label,
color: series.color,
}));
setPlotLegendEntries(panelId, entries);
Expand Down
87 changes: 84 additions & 3 deletions src/features/panels/Plot/plotChart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Time } from '@/core/types/ros';
import { formatDurationNs } from '@/shared/utils/time';
import type { PlotDataset, PlotRuntimeSeries } from './datasets';
import type { PlotXAxisMode } from './defaults';
import { buildPlotTooltipRows, type PlotTooltipSeries } from './plotSeriesDisplay';

export interface PlotChartColors {
axisStroke: string;
Expand Down Expand Up @@ -215,8 +216,11 @@ export function computeRelativeTimeSplits(
interface CursorLabelElements {
x: HTMLDivElement;
y: HTMLDivElement;
tip: HTMLDivElement;
}

const TOOLTIP_MAX_ROWS = 16;

const cursorLabelsByChart = new WeakMap<uPlot, CursorLabelElements>();

function applyCursorLabelStyle(el: HTMLDivElement, colors: PlotChartColors): void {
Expand All @@ -243,22 +247,30 @@ function initCursorLabels(u: uPlot, colors: PlotChartColors): void {
applyCursorLabelStyle(xLabel, colors);
applyCursorLabelStyle(yLabel, colors);

const tip = document.createElement('div');
applyCursorLabelStyle(tip, colors);
tip.style.padding = '4px 6px';
tip.dataset.testid = 'plot-hover-tooltip';

u.over.appendChild(xLabel);
u.over.appendChild(yLabel);
cursorLabelsByChart.set(u, { x: xLabel, y: yLabel });
u.over.appendChild(tip);
cursorLabelsByChart.set(u, { x: xLabel, y: yLabel, tip });
}

function hideCursorLabels(u: uPlot): void {
const labels = cursorLabelsByChart.get(u);
if (!labels) return;
labels.x.style.display = 'none';
labels.y.style.display = 'none';
labels.tip.style.display = 'none';
}

function updateCursorLabels(
u: uPlot,
xAxisMode: PlotXAxisMode,
logStart: Time | undefined,
getTooltipSeries?: () => PlotTooltipSeries[],
): void {
const labels = cursorLabelsByChart.get(u);
if (!labels) return;
Expand Down Expand Up @@ -287,13 +299,77 @@ function updateCursorLabels(
labels.y.style.left = '4px';
labels.y.style.top = `${cy}px`;
labels.y.style.transform = 'translateY(-50%)';

updateHoverTooltip(u, labels.tip, cx, cy, plotW, xAxisMode, logStart, getTooltipSeries);
}

function updateHoverTooltip(
u: uPlot,
tip: HTMLDivElement,
cx: number,
cy: number,
plotW: number,
xAxisMode: PlotXAxisMode,
logStart: Time | undefined,
getTooltipSeries?: () => PlotTooltipSeries[],
): void {
const idx = u.cursor.idx;
if (!getTooltipSeries || idx == null) {
tip.style.display = 'none';
return;
}
const meta = getTooltipSeries();
const shown = meta.map((entry, i) => ({ ...entry, show: entry.show && u.series[i + 1]?.show !== false }));
const rows = buildPlotTooltipRows(u.data as ArrayLike<number | null>[], idx, shown);
if (rows.length === 0) {
tip.style.display = 'none';
return;
}
tip.replaceChildren();
const xVal = u.data[0]?.[idx];
if (xVal != null) {
const head = document.createElement('div');
head.style.opacity = '0.7';
head.textContent = formatPlotXValue(xVal, xAxisMode, logStart);
tip.appendChild(head);
}
for (const row of rows.slice(0, TOOLTIP_MAX_ROWS)) {
const line = document.createElement('div');
line.style.display = 'flex';
line.style.alignItems = 'center';
line.style.gap = '6px';
const swatch = document.createElement('span');
swatch.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:2px;flex:none;background:${row.color}`;
const name = document.createElement('span');
name.textContent = row.label;
name.style.flex = '1';
const val = document.createElement('span');
val.textContent = row.value;
val.style.fontVariantNumeric = 'tabular-nums';
line.append(swatch, name, val);
tip.appendChild(line);
}
if (rows.length > TOOLTIP_MAX_ROWS) {
const more = document.createElement('div');
more.style.opacity = '0.7';
more.textContent = `+${rows.length - TOOLTIP_MAX_ROWS}`;
tip.appendChild(more);
}
tip.style.display = 'block';
// Flip to the left of the cursor when the right side would overflow the plot.
const width = tip.offsetWidth;
const flip = cx + 12 + width > plotW && cx - 12 - width > 0;
tip.style.left = `${flip ? cx - 12 - width : cx + 12}px`;
const maxTop = Math.max(0, u.bbox.height - tip.offsetHeight);
tip.style.top = `${Math.min(Math.max(0, cy - tip.offsetHeight / 2), maxTop)}px`;
}

function destroyCursorLabels(u: uPlot): void {
const labels = cursorLabelsByChart.get(u);
if (!labels) return;
labels.x.remove();
labels.y.remove();
labels.tip.remove();
cursorLabelsByChart.delete(u);
}

Expand Down Expand Up @@ -336,6 +412,8 @@ export interface PlotChartBuildContext {
* behaviour resumes on the next setData/redraw.
*/
isLoading?: () => boolean;
/** Current series labels/colors/units for the hover tooltip (read on every cursor move). */
getTooltipSeries?: () => PlotTooltipSeries[];
}

export type PlotUplotOptions = Omit<uPlot.Options, 'width' | 'height'>;
Expand Down Expand Up @@ -410,7 +488,7 @@ export function createPlotUplotOptions(
hiddenSeries: Set<number>,
ctx: PlotChartBuildContext,
): PlotUplotOptions {
const { colors, xAxisMode, xRange, logStart, getCurrentTimeSec, panelId } = ctx;
const { colors, xAxisMode, xRange, logStart, getCurrentTimeSec, panelId, getTooltipSeries } = ctx;

const useRelativeTimeAxis = xAxisMode === 'timestamp' && logStart != null;
const logStartSec = useRelativeTimeAxis ? timeToSec(logStart) : undefined;
Expand Down Expand Up @@ -446,6 +524,9 @@ export function createPlotUplotOptions(
grid: { show: true, stroke: colors.gridStroke, width: 1 },
stroke: colors.axisStroke,
font: '10px sans-serif',
...(dataset.yUnit
? { label: dataset.yUnit, labelSize: 14, labelFont: '10px sans-serif', size: 56 }
: {}),
},
],
cursor: {
Expand All @@ -467,7 +548,7 @@ export function createPlotUplotOptions(
},
hooks: {
ready: [(u) => initCursorLabels(u, colors)],
setCursor: [(u) => updateCursorLabels(u, xAxisMode, logStart)],
setCursor: [(u) => updateCursorLabels(u, xAxisMode, logStart, getTooltipSeries)],
destroy: [(u) => destroyCursorLabels(u)],
draw: [(u) => drawPlayhead(u, getCurrentTimeSec, colors.playheadStroke)],
},
Expand Down
Loading
Loading