From 989bff9c9a064c48ca2a71c0e561e6940b118134 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 7 Oct 2026 14:40:05 +0200 Subject: [PATCH 1/7] feat(logging-view,simple): add orders to charts --- .../components/simple/plots/PlotWrapper.tsx | 187 +++++++++++++++++- frontend/logging-view/src/lib/orders.ts | 78 ++++++++ .../src/lib/plotStudio/palette.ts | 8 +- .../src/store/slices/plotStudioSlice.ts | 28 +++ .../src/store/slices/sessionSlice.ts | 19 ++ frontend/logging-view/src/types/plotStudio.ts | 2 + frontend/logging-view/src/types/session.ts | 13 ++ 7 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 frontend/logging-view/src/lib/orders.ts diff --git a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx index 303bf9a63..12bda9f88 100644 --- a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx +++ b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx @@ -11,6 +11,13 @@ import { ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, Input, Separator, Tooltip, @@ -24,6 +31,7 @@ import { Lock, Pencil, RefreshCw, + Send, Trash2, Unlock, } from "@workspace/ui/icons"; @@ -35,6 +43,7 @@ import { useShallow } from "zustand/react/shallow"; import { decimateLTTB } from "../../../lib/plotStudio/decimate"; import { computeFFT } from "../../../lib/plotStudio/fft"; import { exportTimestamp } from "../../../lib/plotStudio/format"; +import { formatOrderAnnotation, formatOrderParameters } from "../../../lib/orders"; import { traceColor, resolveSignalColor } from "../../../lib/plotStudio/palette"; import { buildPlotLayout, buildTimelineLayout, getPlotlyTheme } from "../../../lib/plotStudio/plotlyTheme"; import { lowerBound, upperBound } from "../../../lib/plotStudio/range"; @@ -70,6 +79,7 @@ const PLOTLY_CONFIG: Partial = { // is what actually freezes the tab; ~8k is comfortably smooth to paint/pan // and visually indistinguishable at typical screen widths. const DECIMATE_THRESHOLD = 8_000; +const ORDER_EVENT_COLOR = "#ff7f0e"; // Plotly divs expose a Node-style event emitter after newPlot() type PlotlyEventDiv = HTMLDivElement & { @@ -77,6 +87,45 @@ type PlotlyEventDiv = HTMLDivElement & { removeAllListeners?: (event: string) => void; }; +/** Assign labels to vertical lanes. Plotly annotations know no collision + * avoidance, so nearby events would otherwise all occupy the same title + * position. The line itself remains at the exact event timestamp. */ +type OrderLabelPlacement = "top" | "bottom" | "inside"; + +function assignOrderLabelLanes(orders: T[]): Array<{ + order: T; + placement: OrderLabelPlacement; + lane: number; +}> { + if (orders.length === 0) return []; + const sorted = [...orders].sort((a, b) => a.time - b.time); + const range = Math.max(1, sorted[sorted.length - 1].time - sorted[0].time); + const zoneEnds: Record = { top: [], bottom: [], inside: [] }; + const findOpenLane = (placement: OrderLabelPlacement, time: number, width: number, limit: number) => { + const ends = zoneEnds[placement]; + let lane = ends.findIndex((end) => end < time); + if (lane === -1 && ends.length < limit) lane = ends.length; + if (lane === -1) return null; + ends[lane] = time + width; + return lane; + }; + + return sorted.map((order) => { + // Roughly translate label characters to their horizontal share of a + // typical chart. Exact browser text measurement is deliberately avoided + // here so exports and live charts use the same stable lane assignment. + const labelWidth = Math.min(range * 0.32, Math.max(range * 0.055, range * (order.name.length + 24) / 110)); + // Use the clear margins first. Only densely clustered labels fall back + // into the plot body, where they are vertically stacked by lane. + const topLane = findOpenLane("top", order.time, labelWidth, 1); + if (topLane !== null) return { order, placement: "top", lane: topLane }; + const bottomLane = findOpenLane("bottom", order.time, labelWidth, 1); + if (bottomLane !== null) return { order, placement: "bottom", lane: bottomLane }; + const insideLane = findOpenLane("inside", order.time, labelWidth, Number.POSITIVE_INFINITY)!; + return { order, placement: "inside", lane: insideLane }; + }); +} + // Defined outside component — no components created during render function ZoomGroup({ label, @@ -138,11 +187,14 @@ const PlotWrapper = forwardRef(({ plot }, re ); const fftSampleRateOverride = useStore((s) => s.fftSampleRateOverride); const adjData = useStore((s) => s.adjData); + const orders = useStore((s) => s.orders); const removeStudioPlot = useStore((s) => s.removeStudioPlot); const renameStudioPlot = useStore((s) => s.renameStudioPlot); const isDarkMode = useStore((s) => s.isDarkMode); const toggleStudioPlotFFT = useStore((s) => s.toggleStudioPlotFFT); const toggleStudioPlotLocked = useStore((s) => s.toggleStudioPlotLocked); + const toggleStudioPlotOrder = useStore((s) => s.toggleStudioPlotOrder); + const setStudioPlotOrdersVisible = useStore((s) => s.setStudioPlotOrdersVisible); const locked = !!plot.locked; const chartRef = useRef(null); @@ -366,8 +418,56 @@ const PlotWrapper = forwardRef(({ plot }, re const hasTraces = traces.length > 0; + // Orders share the session timebase with telemetry. Render them as paper- + // height vertical event lines so they remain meaningful for plots with any + // unit (and for the categorical timeline). FFT uses frequency, so order + // timestamps intentionally do not appear in that mode. + const visibleOrders = useMemo( + () => !hasFFT ? orders.filter((order) => !plot.hiddenOrderIds?.[order.id]) : [], + [orders, plot.hiddenOrderIds, hasFFT], + ); + const orderLabelLanes = useMemo(() => assignOrderLabelLanes(visibleOrders), [visibleOrders]); + + const addOrderAnnotations = useCallback((baseLayout: Partial) => { + if (orderLabelLanes.length === 0) return baseLayout; + const theme = getPlotlyTheme(isDarkMode); + return { + ...baseLayout, + shapes: [ + ...(baseLayout.shapes ?? []), + ...orderLabelLanes.map(({ order }) => ({ + type: "line" as const, + xref: "x" as const, yref: "paper" as const, + x0: order.time, x1: order.time, y0: 0, y1: 1, + line: { color: ORDER_EVENT_COLOR, width: 1.25, dash: "dot" as const }, + layer: "above" as const, + })), + ], + annotations: [ + ...(baseLayout.annotations ?? []), + ...orderLabelLanes.map(({ order, placement, lane }) => ({ + x: order.time, xref: "x" as const, + // Above margin → below margin → stacked inside plot. + y: placement === "bottom" ? 0 : 1, yref: "paper" as const, + text: formatOrderAnnotation(order.name, order.parameters), + showarrow: false, + xanchor: "left" as const, + yanchor: placement === "top" ? "bottom" as const : "top" as const, + xshift: 3, + yshift: placement === "top" ? 5 : placement === "bottom" ? -5 : -6 - lane * 32, + font: { size: 10, color: theme.fontColor }, + bgcolor: theme.paperBg, + bordercolor: ORDER_EVENT_COLOR, + borderwidth: 1, + opacity: 0.94, + align: "left" as const, + })), + ], + }; + }, [orderLabelLanes, isDarkMode]); + const layout = useMemo>( - () => + () => addOrderAnnotations( hasTimeline ? buildTimelineLayout({ theme: getPlotlyTheme(isDarkMode), plotId: plot.id, rowLabels: timelineRowLabels, locked }) : buildPlotLayout({ @@ -376,7 +476,8 @@ const PlotWrapper = forwardRef(({ plot }, re leftUnits: axisUnits.left, rightUnits: axisUnits.right, locked, }), - [hasLeftAxis, hasRightAxis, hasFFT, hasTimeline, timelineRowLabels, plot.id, axisUnits, isDarkMode, locked], + ), + [hasLeftAxis, hasRightAxis, hasFFT, hasTimeline, timelineRowLabels, plot.id, axisUnits, isDarkMode, locked, addOrderAnnotations], ); // editable (click-to-rename title/legend) is the one PLOTLY_CONFIG option @@ -474,7 +575,7 @@ const PlotWrapper = forwardRef(({ plot }, re const div = chartRef.current?.getDiv(); if (!div) return null; const gd = div as unknown as Plotly.PlotlyHTMLElement & { _fullLayout: Record }; - const exportLayout = hasTimeline + let exportLayout = hasTimeline ? buildTimelineLayout({ theme: getPlotlyTheme(false), plotId: plot.id, rowLabels: timelineRowLabels, fontScale: 1.8, exportHeight }) : buildPlotLayout({ theme: getPlotlyTheme(false), @@ -482,6 +583,29 @@ const PlotWrapper = forwardRef(({ plot }, re leftUnits: axisUnits.left, rightUnits: axisUnits.right, fontScale: 1.8, exportHeight, }); + // Export is always light, so rebuild annotations with its print theme. + if (orderLabelLanes.length > 0) { + const printTheme = getPlotlyTheme(false); + exportLayout = { + ...exportLayout, + shapes: [...(exportLayout.shapes ?? []), ...orderLabelLanes.map(({ order }) => ({ + type: "line" as const, xref: "x" as const, yref: "paper" as const, + x0: order.time, x1: order.time, y0: 0, y1: 1, + line: { color: ORDER_EVENT_COLOR, width: 2, dash: "dot" as const }, layer: "above" as const, + }))], + annotations: [...(exportLayout.annotations ?? []), ...orderLabelLanes.map(({ order, placement, lane }) => ({ + x: order.time, xref: "x" as const, + y: placement === "bottom" ? 0 : 1, yref: "paper" as const, + text: formatOrderAnnotation(order.name, order.parameters), + showarrow: false, xanchor: "left" as const, + yanchor: placement === "top" ? "bottom" as const : "top" as const, + xshift: 5, + yshift: placement === "top" ? 8 : placement === "bottom" ? -8 : -10 - lane * 56, + font: { size: 18, color: printTheme.fontColor }, + bgcolor: printTheme.paperBg, bordercolor: ORDER_EVENT_COLOR, borderwidth: 1.5, align: "left" as const, + }))], + }; + } // Carry over whatever title the user typed via Plotly's click-to-edit — // gd.layout is Plotly's live, mutated layout (relayout writes // title.text into it), whereas exportLayout is rebuilt fresh from @@ -624,6 +748,63 @@ const PlotWrapper = forwardRef(({ plot }, re
+ {orders.length > 0 && ( + + + + + + + + Orders shown on this plot + + + Orders for {plot.name} + +
+ { event.preventDefault(); setStudioPlotOrdersVisible(plot.id, orders.map((order) => order.id), true); }} + > + Show all + + { event.preventDefault(); setStudioPlotOrdersVisible(plot.id, orders.map((order) => order.id), false); }} + > + Hide all + +
+ + {orders.map((order) => ( + toggleStudioPlotOrder(plot.id, order.id)} + className="items-start py-2" + > + + + {order.name} + {order.time.toFixed(0)} ms + + + {formatOrderParameters(order.parameters)} + + + + ))} +
+
+ )} {/* Zoom cluster — only meaningful with traces, and hidden while locked since the axes are fixedrange (see `layout` above) and these buttons would otherwise bypass that via direct relayout calls. */} diff --git a/frontend/logging-view/src/lib/orders.ts b/frontend/logging-view/src/lib/orders.ts new file mode 100644 index 000000000..bff1890c5 --- /dev/null +++ b/frontend/logging-view/src/lib/orders.ts @@ -0,0 +1,78 @@ +import type { AdjArchive, AdjPacket, LoggedOrder } from "../types/session"; + +const TIME_UNIT_TO_MS: Record = { ns: 1e-6, us: 1e-3, ms: 1, s: 1e3 }; + +function escapePlotlyText(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +// Order parameters are JSON and can contain commas, so a plain split(",") is +// not sufficient for the backend's CSV rows. +function splitCsvRow(row: string): string[] { + const fields: string[] = []; + let field = ""; + let quoted = false; + for (let i = 0; i < row.length; i++) { + const char = row[i]; + if (char === '"') { + if (quoted && row[i + 1] === '"') { field += char; i++; } + else quoted = !quoted; + } else if (char === "," && !quoted) { + fields.push(field); + field = ""; + } else field += char; + } + fields.push(field); + return fields; +} + +function orderName(adjData: AdjArchive | null, board: string, id: number): string { + const group = adjData?.boards[board]; + for (const key of ["orders", "orders_old"]) { + const orders = group?.[key]; + if (Array.isArray(orders)) { + const order = (orders as AdjPacket[]).find((packet) => packet.id === id); + if (order?.name) return order.name; + } + } + return `Order ${id}`; +} + +/** Parses the backend's order/order.csv file. Invalid rows are skipped so an + * otherwise useful session remains viewable if one record is malformed. */ +export function parseOrdersCsv(text: string, timeUnit: string, adjData: AdjArchive | null): LoggedOrder[] { + const multiplier = TIME_UNIT_TO_MS[timeUnit] ?? 1; + const records: Array & { rawTime: number }> = []; + + for (const row of text.split(/\r?\n/)) { + if (!row.trim()) continue; + const [timestamp, from, to, orderId, rawParameters] = splitCsvRow(row); + const rawTime = Number(timestamp); + const numericOrderId = Number(orderId); + if (!Number.isFinite(rawTime) || !Number.isInteger(numericOrderId)) continue; + let parameters: Record | null = null; + try { + const parsed: unknown = JSON.parse(rawParameters); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) parameters = parsed as Record; + } catch { /* Older logs may have a non-JSON map representation. */ } + records.push({ rawTime, from, to, orderId: numericOrderId, parameters, name: orderName(adjData, to, numericOrderId) }); + } + + const start = records[0]?.rawTime ?? 0; + return records.map(({ rawTime, ...record }, index) => ({ + ...record, + id: `${rawTime}-${record.to}-${record.orderId}-${index}`, + time: (rawTime - start) * multiplier, + })); +} + +export function formatOrderParameters(parameters: Record | null): string { + if (!parameters || Object.keys(parameters).length === 0) return "No parameters"; + return Object.entries(parameters) + .map(([key, value]) => `${escapePlotlyText(key)}: ${escapePlotlyText(typeof value === "string" ? value : JSON.stringify(value))}`) + .join(" · "); +} + +export function formatOrderAnnotation(name: string, parameters: Record | null): string { + return `${escapePlotlyText(name)}
${formatOrderParameters(parameters)}`; +} diff --git a/frontend/logging-view/src/lib/plotStudio/palette.ts b/frontend/logging-view/src/lib/plotStudio/palette.ts index 0c788cba6..fac2f2e8f 100644 --- a/frontend/logging-view/src/lib/plotStudio/palette.ts +++ b/frontend/logging-view/src/lib/plotStudio/palette.ts @@ -1,8 +1,9 @@ -// D3 Category10 — Plotly's default colorway, pinned explicitly so the UI -// (signal chips, stats headers) can show the exact color of each trace. +// D3-inspired palette, pinned explicitly so the UI (signal chips, stats +// headers) can show the exact color of each trace. Orange is reserved for +// order-event markers, so it is deliberately not assigned to the second +// signal (the usual secondary/right-axis series). export const TRACE_COLORS = [ "#1f77b4", // blue - "#ff7f0e", // orange "#2ca02c", // green "#d62728", // red "#9467bd", // purple @@ -11,6 +12,7 @@ export const TRACE_COLORS = [ "#7f7f7f", // gray "#bcbd22", // olive "#17becf", // cyan + "#0f766e", // teal ] as const; export const traceColor = (index: number): string => diff --git a/frontend/logging-view/src/store/slices/plotStudioSlice.ts b/frontend/logging-view/src/store/slices/plotStudioSlice.ts index b94244a4d..47b077994 100644 --- a/frontend/logging-view/src/store/slices/plotStudioSlice.ts +++ b/frontend/logging-view/src/store/slices/plotStudioSlice.ts @@ -42,6 +42,8 @@ export interface PlotStudioSlice { toggleStudioPlotFFT: (plotId: string) => void; toggleStudioPlotLocked: (plotId: string) => void; updateStudioSignalColor: (plotId: string, signalId: string, color: string) => void; + toggleStudioPlotOrder: (plotId: string, orderId: string) => void; + setStudioPlotOrdersVisible: (plotId: string, orderIds: string[], visible: boolean) => void; setStudioFFTSampleRate: (rate: number | null) => void; setSignalLoadWarning: (message: string | null) => void; setPlotAddedToast: (name: string | null) => void; @@ -250,6 +252,32 @@ export const createPlotStudioSlice: StateCreator = (set) => ({ return { studioPlots: next }; }), + toggleStudioPlotOrder: (plotId, orderId) => + set((s) => { + const plot = s.studioPlots.get(plotId); + if (!plot) return {}; + const hiddenOrderIds = { ...plot.hiddenOrderIds }; + if (hiddenOrderIds[orderId]) delete hiddenOrderIds[orderId]; + else hiddenOrderIds[orderId] = true; + const next = new Map(s.studioPlots); + next.set(plotId, { ...plot, hiddenOrderIds }); + return { studioPlots: next }; + }), + + setStudioPlotOrdersVisible: (plotId, orderIds, visible) => + set((s) => { + const plot = s.studioPlots.get(plotId); + if (!plot) return {}; + const hiddenOrderIds = { ...plot.hiddenOrderIds }; + for (const orderId of orderIds) { + if (visible) delete hiddenOrderIds[orderId]; + else hiddenOrderIds[orderId] = true; + } + const next = new Map(s.studioPlots); + next.set(plotId, { ...plot, hiddenOrderIds }); + return { studioPlots: next }; + }), + setStudioFFTSampleRate: (rate) => set({ fftSampleRateOverride: rate }), setSignalLoadWarning: (message) => set({ signalLoadWarning: message }), diff --git a/frontend/logging-view/src/store/slices/sessionSlice.ts b/frontend/logging-view/src/store/slices/sessionSlice.ts index 8a5f87e75..7dd900c89 100644 --- a/frontend/logging-view/src/store/slices/sessionSlice.ts +++ b/frontend/logging-view/src/store/slices/sessionSlice.ts @@ -15,9 +15,11 @@ import type { AdjStatus, DroppedFile, LoggerSettings, + LoggedOrder, SeriesKey, SessionStatus, } from "../../types/session"; +import { parseOrdersCsv } from "../../lib/orders"; import type { Store } from "../store"; const ADJ_ARCHIVE_URL = (hash: string) => @@ -65,6 +67,7 @@ export interface SessionSlice { selectedSeries: Record; // All session files keyed by webkitRelativePath, for later CSV reading. sessionFiles: Map; + orders: LoggedOrder[]; isLoading: boolean; // Persistent — reflects the currently open session, cleared only by clearSession. sessionStatus: SessionStatus | null; @@ -93,6 +96,7 @@ export const createSessionSlice: StateCreator = (se availableSeries: {}, selectedSeries: {}, sessionFiles: new Map(), + orders: [], isLoading: false, sessionStatus: null, sessionStatusToast: null, @@ -169,6 +173,19 @@ export const createSessionSlice: StateCreator = (se } } + // Orders are optional session events. Their absence or malformed content + // never changes the telemetry session status. + let orders: LoggedOrder[] = []; + const orderFile = fileArray.find((f) => { + const parts = f.webkitRelativePath.split("/"); + return f.name === "order.csv" && parts.length === 3 && parts[1] === "order"; + }); + if (orderFile) { + try { + orders = parseOrdersCsv(await orderFile.text(), settings?.time_unit ?? "ms", adjData); + } catch { /* Leave events empty when an optional order file cannot be read. */ } + } + const status = computeSessionStatus(settingsIssue, availableSeries, adjStatus); set({ @@ -178,6 +195,7 @@ export const createSessionSlice: StateCreator = (se availableSeries, selectedSeries: {}, sessionFiles: new Map(fileArray.map((f) => [f.webkitRelativePath, f])), + orders, isLoading: false, isSessionPanelOpen: true, sessionStatus: status, @@ -226,6 +244,7 @@ export const createSessionSlice: StateCreator = (se availableSeries: {}, selectedSeries: {}, sessionFiles: new Map(), + orders: [], sessionStatus: null, sessionStatusToast: null, isSessionPanelOpen: true, diff --git a/frontend/logging-view/src/types/plotStudio.ts b/frontend/logging-view/src/types/plotStudio.ts index df4b44f70..e14d78ec0 100644 --- a/frontend/logging-view/src/types/plotStudio.ts +++ b/frontend/logging-view/src/types/plotStudio.ts @@ -67,6 +67,8 @@ export interface PlotState { // one plot (they'd need incompatible X-axis semantics on the same axis). showFFT: boolean; nextColorIndex: number; // monotonically increasing; never reused, so colors don't reshuffle on removal + /** Order event ids hidden only on this plot. Missing ids remain visible. */ + hiddenOrderIds?: Record; // When true, blocks pan/zoom/scroll-zoom, drag-to-resize, inline rename and // signal drops onto this plot, so the view can't be changed by accident. locked?: boolean; diff --git a/frontend/logging-view/src/types/session.ts b/frontend/logging-view/src/types/session.ts index 65548412a..2449cbe95 100644 --- a/frontend/logging-view/src/types/session.ts +++ b/frontend/logging-view/src/types/session.ts @@ -81,3 +81,16 @@ export interface DroppedFile { webkitRelativePath: string; text: () => Promise; } + +/** An order emitted by the backend during a logging session. Times are + * normalized to milliseconds, matching plotted telemetry signals. */ +export interface LoggedOrder { + id: string; + time: number; + from: string; + to: string; + orderId: number; + parameters: Record | null; + /** Resolved from the session ADJ when it is available. */ + name: string; +} From ddb9bde590d56b3ad795c79ce33b26b4db03dbe9 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 12:31:39 +0200 Subject: [PATCH 2/7] fix(logging-view): overlaping title and order box --- .../src/components/simple/plots/PlotWrapper.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx index 12bda9f88..e6d463577 100644 --- a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx +++ b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx @@ -447,14 +447,15 @@ const PlotWrapper = forwardRef(({ plot }, re ...(baseLayout.annotations ?? []), ...orderLabelLanes.map(({ order, placement, lane }) => ({ x: order.time, xref: "x" as const, - // Above margin → below margin → stacked inside plot. + // Keep the first lane just inside the plot. Placing it in the top + // margin makes it overlap Plotly's editable chart title. y: placement === "bottom" ? 0 : 1, yref: "paper" as const, text: formatOrderAnnotation(order.name, order.parameters), showarrow: false, xanchor: "left" as const, - yanchor: placement === "top" ? "bottom" as const : "top" as const, + yanchor: "top" as const, xshift: 3, - yshift: placement === "top" ? 5 : placement === "bottom" ? -5 : -6 - lane * 32, + yshift: placement === "bottom" ? -5 : -6 - lane * 32, font: { size: 10, color: theme.fontColor }, bgcolor: theme.paperBg, bordercolor: ORDER_EVENT_COLOR, @@ -598,9 +599,9 @@ const PlotWrapper = forwardRef(({ plot }, re y: placement === "bottom" ? 0 : 1, yref: "paper" as const, text: formatOrderAnnotation(order.name, order.parameters), showarrow: false, xanchor: "left" as const, - yanchor: placement === "top" ? "bottom" as const : "top" as const, + yanchor: "top" as const, xshift: 5, - yshift: placement === "top" ? 8 : placement === "bottom" ? -8 : -10 - lane * 56, + yshift: placement === "bottom" ? -8 : -10 - lane * 56, font: { size: 18, color: printTheme.fontColor }, bgcolor: printTheme.paperBg, bordercolor: ORDER_EVENT_COLOR, borderwidth: 1.5, align: "left" as const, }))], From 9795ce95e3d3286ed7c331814875a642410eaa1a Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 12:58:44 +0200 Subject: [PATCH 3/7] fix(logging-view, simple): put orders to the right menu and ad board origin --- .../components/simple/plots/PlotWrapper.tsx | 69 +----------- .../simple/sidebar/PlotsSection.tsx | 106 +++++++++++++++++- 2 files changed, 106 insertions(+), 69 deletions(-) diff --git a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx index e6d463577..0d4865fb5 100644 --- a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx +++ b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx @@ -11,13 +11,6 @@ import { ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, Input, Separator, Tooltip, @@ -31,7 +24,6 @@ import { Lock, Pencil, RefreshCw, - Send, Trash2, Unlock, } from "@workspace/ui/icons"; @@ -43,7 +35,7 @@ import { useShallow } from "zustand/react/shallow"; import { decimateLTTB } from "../../../lib/plotStudio/decimate"; import { computeFFT } from "../../../lib/plotStudio/fft"; import { exportTimestamp } from "../../../lib/plotStudio/format"; -import { formatOrderAnnotation, formatOrderParameters } from "../../../lib/orders"; +import { formatOrderAnnotation } from "../../../lib/orders"; import { traceColor, resolveSignalColor } from "../../../lib/plotStudio/palette"; import { buildPlotLayout, buildTimelineLayout, getPlotlyTheme } from "../../../lib/plotStudio/plotlyTheme"; import { lowerBound, upperBound } from "../../../lib/plotStudio/range"; @@ -193,8 +185,6 @@ const PlotWrapper = forwardRef(({ plot }, re const isDarkMode = useStore((s) => s.isDarkMode); const toggleStudioPlotFFT = useStore((s) => s.toggleStudioPlotFFT); const toggleStudioPlotLocked = useStore((s) => s.toggleStudioPlotLocked); - const toggleStudioPlotOrder = useStore((s) => s.toggleStudioPlotOrder); - const setStudioPlotOrdersVisible = useStore((s) => s.setStudioPlotOrdersVisible); const locked = !!plot.locked; const chartRef = useRef(null); @@ -749,63 +739,6 @@ const PlotWrapper = forwardRef(({ plot }, re
- {orders.length > 0 && ( - - - - - - - - Orders shown on this plot - - - Orders for {plot.name} - -
- { event.preventDefault(); setStudioPlotOrdersVisible(plot.id, orders.map((order) => order.id), true); }} - > - Show all - - { event.preventDefault(); setStudioPlotOrdersVisible(plot.id, orders.map((order) => order.id), false); }} - > - Hide all - -
- - {orders.map((order) => ( - toggleStudioPlotOrder(plot.id, order.id)} - className="items-start py-2" - > - - - {order.name} - {order.time.toFixed(0)} ms - - - {formatOrderParameters(order.parameters)} - - - - ))} -
-
- )} {/* Zoom cluster — only meaningful with traces, and hidden while locked since the axes are fixedrange (see `layout` above) and these buttons would otherwise bypass that via direct relayout calls. */} diff --git a/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx b/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx index 3a18631e2..f1dd697ed 100644 --- a/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx +++ b/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx @@ -5,6 +5,7 @@ // chip matches the trace color in the chart (lib/plotStudio/palette). import { Button, + Checkbox, ContextMenu, ContextMenuContent, ContextMenuItem, @@ -22,12 +23,13 @@ import { TooltipContent, TooltipTrigger, } from "@workspace/ui/components"; -import { Activity, Eye, EyeOff, GripVertical, Plus, Trash2, X } from "@workspace/ui/icons"; +import { Activity, ChevronDown, Eye, EyeOff, GripVertical, Plus, Send, Trash2, X } from "@workspace/ui/icons"; import { cn } from "@workspace/ui/lib"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { resolveSignalColor } from "../../../lib/plotStudio/palette"; import { getSignalName } from "../../../lib/plotStudio/units"; +import { formatOrderParameters } from "../../../lib/orders"; import { getSignal } from "../../../store/slices/plotStudioSlice"; import { useStore } from "../../../store/store"; import { useSignalLoader } from "../hooks/useStudioSignals"; @@ -59,12 +61,17 @@ export default function PlotsSection() { const updateStudioSignalAxis = useStore((s) => s.updateStudioSignalAxis); const toggleStudioPlotFFT = useStore((s) => s.toggleStudioPlotFFT); const updateStudioSignalColor = useStore((s) => s.updateStudioSignalColor); + const orders = useStore((s) => s.orders); + const toggleStudioPlotOrder = useStore((s) => s.toggleStudioPlotOrder); + const setStudioPlotOrdersVisible = useStore((s) => s.setStudioPlotOrdersVisible); const ensureLoaded = useSignalLoader(); // Plot ids with a CSV parse in flight (shows "Loading…" in the trigger) const [assigning, setAssigning] = useState>(new Set()); // Drag-to-reorder: id of the plot currently being dragged const [draggingId, setDraggingId] = useState(null); + // Order lists can be long, so let each plot keep its own collapsed state. + const [collapsedOrderSections, setCollapsedOrderSections] = useState>(new Set()); const plots = Array.from(studioPlots.values()); @@ -278,6 +285,103 @@ export default function PlotsSection() {
)} + {orders.length > 0 && ( +
+ + + {!collapsedOrderSections.has(plot.id) && ( +
+
+ + +
+ +
+ {orders.map((order) => { + const checked = !plot.hiddenOrderIds?.[order.id]; + return ( + + ); + })} +
+
+ )} +
+ )} + {/* Assign dropdown — grouped by board, hides already-assigned */}
0 ? "border-t pt-1.5" : "pt-2")}> Date: Fri, 9 Oct 2026 17:01:09 +0200 Subject: [PATCH 4/7] feat(logging-view): add menu to see orders only --- frontend/logging-view/package.json | 1 + .../src/components/sidebar/AppSidebar.tsx | 49 ++-- .../sidebar/OrdersTimelineControl.tsx | 229 ++++++++++++++++++ pnpm-lock.yaml | 25 ++ 4 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 frontend/logging-view/src/components/sidebar/OrdersTimelineControl.tsx diff --git a/frontend/logging-view/package.json b/frontend/logging-view/package.json index c84682d79..1d1d809cd 100644 --- a/frontend/logging-view/package.json +++ b/frontend/logging-view/package.json @@ -19,6 +19,7 @@ "@vitejs/plugin-react-swc": "^4.2.3", "@workspace/core": "workspace:*", "@workspace/ui": "workspace:*", + "echarts": "^6.1.0", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.8", "lucide-react": "^0.563.0", diff --git a/frontend/logging-view/src/components/sidebar/AppSidebar.tsx b/frontend/logging-view/src/components/sidebar/AppSidebar.tsx index 7d2f61a26..b8339a684 100644 --- a/frontend/logging-view/src/components/sidebar/AppSidebar.tsx +++ b/frontend/logging-view/src/components/sidebar/AppSidebar.tsx @@ -9,33 +9,44 @@ import { SidebarHeader, SidebarSeparator, } from "@workspace/ui/components"; +import { useStore } from "../../store/store"; import FolderPickerGroup from "./FolderPickerGroup"; +import OrdersTimelineControl from "./OrdersTimelineControl"; import SeriesGroup from "./SeriesGroup"; import SeriesSelectionBar from "./SeriesSelectionBar"; import SidebarToggleHandle from "./SidebarToggleHandle"; import ThemeToggleItem from "./ThemeToggleItem"; -const AppSidebar = ({ ...props }: React.ComponentProps) => ( - - {/* Fixed — always visible regardless of scroll position */} - - - - - +const AppSidebar = ({ ...props }: React.ComponentProps) => { + const hasLoadedSession = useStore((s) => s.folderName !== null); - {/* Scrollable area for series selection */} - - - + return ( + + {/* Fixed — always visible regardless of scroll position */} + + + + + - -
- - + {/* Scrollable area for series selection */} + + + - - -); + + {hasLoadedSession && ( + <> + + + + )} + + + + + + ); +}; export default AppSidebar; diff --git a/frontend/logging-view/src/components/sidebar/OrdersTimelineControl.tsx b/frontend/logging-view/src/components/sidebar/OrdersTimelineControl.tsx new file mode 100644 index 000000000..eae17f805 --- /dev/null +++ b/frontend/logging-view/src/components/sidebar/OrdersTimelineControl.tsx @@ -0,0 +1,229 @@ +// A session-wide view of command events. Orders are not telemetry series, so +// they live in the sidebar footer and open their own time-oriented view. +import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem } from "@workspace/ui/components"; +import { Send } from "@workspace/ui/icons"; +import * as echarts from "echarts"; +import { useEffect, useRef, useState } from "react"; +import { formatOrderAnnotation } from "../../lib/orders"; +import { useStore } from "../../store/store"; + +interface OrderPoint { + id: string; + value: [number, string]; + from: string; + to: string; + annotation: string; +} + +const escapeHtml = (value: string) => value.replace(/&/g, "&").replace(//g, ">"); +const routeColumnWidth = (chartWidth: number) => Math.min(220, Math.max(120, Math.round(chartWidth * 0.28))); +const describeParameters = (parameters: Record | null) => + parameters && Object.keys(parameters).length > 0 + ? Object.entries(parameters).map(([key, value]) => `${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`).join(" · ") + : "No parameters"; + +function OrdersTimelineChart() { + const orders = useStore((s) => s.orders); + const isDarkMode = useStore((s) => s.isDarkMode); + const elementRef = useRef(null); + const chartRef = useRef(null); + const rangeRef = useRef({ start: 0, end: 100 }); + const [selectedId, setSelectedId] = useState(null); + const selectedOrder = orders.find((order) => order.id === selectedId) ?? orders[0]; + const routeCount = new Set(orders.map((order) => `${order.from} → ${order.to}`)).size; + + const zoom = (factor: number) => { + const chart = chartRef.current; + if (!chart) return; + const { start, end } = rangeRef.current; + const width = Math.min(100, Math.max(1, (end - start) * factor)); + const midpoint = (start + end) / 2; + const nextStart = Math.max(0, Math.min(100 - width, midpoint - width / 2)); + chart.dispatchAction({ type: "dataZoom", start: nextStart, end: nextStart + width }); + }; + + const resetZoom = () => { + chartRef.current?.dispatchAction({ type: "dataZoom", start: 0, end: 100 }); + }; + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + const chart = echarts.init(element); + chartRef.current = chart; + const updateRange = () => { + const current = (chart.getOption() as { dataZoom?: Array<{ start?: number; end?: number }> }).dataZoom?.[0]; + if (current?.start !== undefined && current.end !== undefined) { + rangeRef.current = { start: current.start, end: current.end }; + } + }; + chart.on("datazoom", updateRange); + const selectOrder = (params: { data?: unknown }) => { + const point = params.data as OrderPoint | undefined; + if (point?.id) setSelectedId(point.id); + }; + chart.on("click", selectOrder); + const observer = new ResizeObserver(() => { + const labelSpace = routeColumnWidth(element.clientWidth); + chart.setOption({ grid: { left: labelSpace }, yAxis: { axisLabel: { width: labelSpace - 30 } } }); + chart.resize(); + }); + observer.observe(element); + return () => { + observer.disconnect(); + chart.off("datazoom", updateRange); + chart.off("click", selectOrder); + chart.dispose(); + chartRef.current = null; + }; + }, []); + + useEffect(() => { + const chart = chartRef.current; + if (!chart) return; + const rows = [...new Set(orders.map((order) => `${order.from} → ${order.to}`))]; + const points: OrderPoint[] = orders.map((order) => ({ + id: order.id, + value: [order.time, `${order.from} → ${order.to}`], + from: order.from, + to: order.to, + annotation: formatOrderAnnotation(order.name, order.parameters), + })); + const text = isDarkMode ? "#f9fafb" : "#111827"; + const grid = isDarkMode ? "#383838" : "#e5e7eb"; + const background = isDarkMode ? "#181818" : "#ffffff"; + const labelSpace = routeColumnWidth(elementRef.current?.clientWidth ?? 0); + + chart.setOption({ + backgroundColor: "transparent", + animation: false, + grid: { left: labelSpace, right: 28, top: 16, bottom: 56 }, + tooltip: { + trigger: "item", + backgroundColor: isDarkMode ? "rgba(24,24,24,0.96)" : "rgba(255,255,255,0.98)", + borderColor: grid, + textStyle: { color: text }, + formatter: (params: { data: unknown }) => { + const point = params.data as OrderPoint; + return `${point.annotation}
From: ${escapeHtml(point.from)}
To: ${escapeHtml(point.to)}
Time: ${point.value[0].toFixed(0)} ms`; + }, + }, + xAxis: { + type: "value", + name: "Time (ms)", + nameLocation: "middle", + nameGap: 34, + nameTextStyle: { color: isDarkMode ? "#a1a1aa" : "#6b7280" }, + axisLine: { lineStyle: { color: grid } }, + axisLabel: { color: isDarkMode ? "#a1a1aa" : "#6b7280", hideOverlap: true }, + splitLine: { lineStyle: { color: grid, type: "dashed", opacity: 0.7 } }, + }, + yAxis: { + type: "category", + data: rows, + inverse: true, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: text, width: labelSpace - 30, overflow: "truncate", margin: 12 }, + splitLine: { show: false }, + splitArea: { show: true, areaStyle: { color: ["transparent", isDarkMode ? "#202020" : "#f8fafc"] } }, + }, + dataZoom: [{ type: "inside", xAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: false }], + series: [{ + type: "scatter", + data: points, + symbolSize: 13, + itemStyle: { color: "#ff7f0e", borderColor: background, borderWidth: 2 }, + emphasis: { scale: 1.3 }, + }], + }, true); + }, [orders, isDarkMode]); + + return ( +
+
+
+

Execution timeline

+

Wheel to zoom · drag to move · select a marker for details

+
+
+ + + +
+
+
From → To
+
+ {selectedOrder && ( +
+
+

{selectedOrder.name}

+

+ {selectedOrder.from} → {selectedOrder.to} · {describeParameters(selectedOrder.parameters)} +

+
+ {selectedOrder.time.toLocaleString()} ms +
+ )} +
+ ); +} + +function OrdersTimelineModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const orders = useStore((s) => s.orders); + const routeCount = new Set(orders.map((order) => `${order.from} → ${order.to}`)).size; + + return ( + { if (!nextOpen) onClose(); }}> + + +
+
+ +
+ Orders +
+

+ {orders.length} command event{orders.length !== 1 && "s"} across {routeCount} route{routeCount !== 1 && "s"} +

+
+ {orders.length > 0 ? ( +
+ +
+ ) : ( +
+ No orders were recorded in this session. +
+ )} +
+
+ ); +} + +export default function OrdersTimelineControl() { + const [open, setOpen] = useState(false); + const ordersCount = useStore((s) => s.orders.length); + + return ( + <> + + + setOpen(true)}> + + Orders + + {ordersCount > 0 && ( + + {ordersCount} + + )} + + + setOpen(false)} /> + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62b462a5e..eb7cd7363 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -425,6 +425,9 @@ importers: '@workspace/ui': specifier: workspace:* version: link:../frontend-kit/ui + echarts: + specifier: ^6.1.0 + version: 6.1.0 jspdf: specifier: ^4.2.1 version: 4.2.1 @@ -3157,6 +3160,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -3333,6 +3339,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3343,6 +3350,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -5372,6 +5380,9 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -5749,6 +5760,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} @@ -8639,6 +8653,11 @@ snapshots: eastasianwidth@0.2.0: {} + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -11250,6 +11269,8 @@ snapshots: tslib@1.14.1: {} + tslib@2.3.0: {} + tslib@2.8.1: {} turbo-darwin-64@2.8.3: @@ -11656,6 +11677,10 @@ snapshots: zod@4.3.6: {} + zrender@6.1.0: + dependencies: + tslib: 2.3.0 + zustand@5.0.11(@types/react@19.2.11)(immer@11.1.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): optionalDependencies: '@types/react': 19.2.11 From 8a5b7e41de2c29d57ef69fe5075b6aa22caf2605 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 17:13:15 +0200 Subject: [PATCH 5/7] fix(logging-view, simple): dark mode color scheme --- .../src/components/simple/plots/PlotWrapper.tsx | 6 ++++-- .../logging-view/src/lib/plotStudio/plotlyTheme.ts | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx index 0d4865fb5..aba0d67d9 100644 --- a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx +++ b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx @@ -663,6 +663,8 @@ const PlotWrapper = forwardRef(({ plot }, re [plot.signals, resolvedSignals, adjData], ); + const chartSurface = getPlotlyTheme(isDarkMode).paperBg; + return ( @@ -679,7 +681,7 @@ const PlotWrapper = forwardRef(({ plot }, re
{/* Header */} -
+
+ + + + + + +
+

Signals & units

+

+ Choose signals to compare in this panel. +

+
+
+ {allSignals.map((signal) => { + const checked = node.signalIds.includes(signal.id); + return ( +
+ + {checked && !signal.discrete && ( + + )} +
+ ); + })} +
+
+ +

+ {tooManyUnits + ? "More than two unit scales use relative trends. Split this panel to inspect original scales." + : "Original values use a separate axis for each unit. Editing a unit label does not convert values."} +

+
+
+
+ + {canClose && ( + + )} +
+
+
+ {signals.map((signal) => ( + + + + {signal.board} / {signal.name} + + {signal.units && ( + + {signal.units} + + )} + + ))} + {normalized && ( + Relative % + )} +
+
+ {signals.length > 0 ? ( + + ) : ( +
+ {node.signalIds.length + ? "Loading signal…" + : "Use the settings button to add signals to this panel."} +
+ )} +
+ + ); +} + +interface LayoutNodeProps extends Omit< + PaneProps, + "node" | "index" | "focused" +> { + node: ChartLayout; + panes: ChartPane[]; + focusedId: string | null; + onResizeStart: () => void; +} + +function LayoutNode({ + node, + panes, + focusedId, + onResizeStart, + ...props +}: LayoutNodeProps) { + if (node.type === "pane") + return ( + pane.id === node.id)} + focused={focusedId === node.id} + /> + ); + const firstSize = minimumLayoutSize(node.first); + const secondSize = minimumLayoutSize(node.second); + const dimension = node.direction === "horizontal" ? "width" : "height"; + return ( + + + + + { + if (event.key.startsWith("Arrow")) onResizeStart(); + }} + className="[&>div]:bg-muted [&>div]:border-border mx-1 w-0.5 shrink-0 bg-transparent aria-[orientation=horizontal]:mx-0 aria-[orientation=horizontal]:my-1 aria-[orientation=horizontal]:h-0.5" + /> + + + + + ); +} + +export default function AnalysisWorkspace({ + signals, + selectedIds, + isDarkMode, +}: { + signals: ComparisonSignal[]; + selectedIds: string[]; + isDarkMode: boolean; +}) { + const syncGroup = useId(); + const [layoutState, setLayoutState] = useState(() => ({ + ids: selectedIds, + tree: createLayout(selectedIds, "grid"), + preset: "grid" as LayoutPreset | null, + })); + const [focusedId, setFocusedId] = useState(null); + const [units, setUnits] = useState>({}); + const [timeUnit, setTimeUnit] = useState("s"); + const [range, setRange] = useState({ start: 0, end: 100 }); + + if (layoutState.ids !== selectedIds) { + setLayoutState({ + ...layoutState, + ids: selectedIds, + tree: layoutState.preset + ? createLayout(selectedIds, layoutState.preset) + : reconcileLayout(layoutState.tree, layoutState.ids, selectedIds), + }); + } + + useEffect( + () => () => { + echarts.disconnect(syncGroup); + }, + [syncGroup], + ); + + const resolvedSignals = useMemo( + () => + signals.map((signal) => ({ + ...signal, + units: units[signal.id] ?? signal.units, + })), + [signals, units], + ); + const domain = useMemo( + () => ({ + startMs: Math.min(...signals.map((signal) => signal.data.time[0])), + endMs: Math.max( + ...signals.map( + (signal) => signal.data.time[signal.data.time.length - 1], + ), + ), + }), + [signals], + ); + const panes = getPanes(layoutState.tree); + const focused = panes.find((pane) => pane.id === focusedId); + const visibleLayout = focused ?? layoutState.tree; + const size = minimumLayoutSize(visibleLayout); + + const changeRange = useCallback((next: TimeRange) => { + setRange((current) => + Math.abs(current.start - next.start) < 0.0001 && + Math.abs(current.end - next.end) < 0.0001 + ? current + : next, + ); + }, []); + const changePane = useCallback( + ( + id: string, + changes: Partial>, + ) => { + setLayoutState((current) => ({ + ...current, + preset: null, + tree: updatePane(current.tree, id, (pane) => ({ ...pane, ...changes })), + })); + }, + [], + ); + const split = (id: string, direction: "horizontal" | "vertical") => { + setLayoutState((current) => ({ + ...current, + preset: null, + tree: splitPane(current.tree, id, direction, selectedIds), + })); + }; + const close = (id: string) => { + setFocusedId(null); + setLayoutState((current) => ({ + ...current, + preset: null, + tree: removePane(current.tree, id) ?? current.tree, + })); + }; + const zoom = (factor: number) => { + const span = Math.min( + 100, + Math.max(0.01, (range.end - range.start) * factor), + ); + const start = Math.max( + 0, + Math.min(100 - span, (range.start + range.end - span) / 2), + ); + changeRange({ start, end: start + span }); + }; + const duration = (domain.endMs - domain.startMs) / TIME_DIVISORS[timeUnit]; + const formatTime = (percent: number) => + ((duration * percent) / 100).toLocaleString(undefined, { + maximumFractionDigits: 3, + }); + + return ( +
+
+
+ + Layout + +
+ {PRESETS.map(({ value, label, Icon }) => ( + + ))} +
+ + + Linked time + +
+
+ +
+ + + +
+
+
+
+ 1} + onRangeChange={changeRange} + onSplit={split} + onClose={close} + onFocus={(id) => + setFocusedId((current) => (current === id ? null : id)) + } + onChange={changePane} + onUnitChange={(id, unit) => + setUnits((current) => ({ ...current, [id]: unit })) + } + onResizeStart={() => + setLayoutState((current) => ({ ...current, preset: null })) + } + /> +
+
+
+ + Scroll to zoom · Drag charts to pan · Drag dividers to resize + + + {panes.length} panels · {formatTime(range.start)}– + {formatTime(range.end)} {timeUnit} + +
+
+ ); +} diff --git a/frontend/logging-view/src/components/normal/ComparisonChart.tsx b/frontend/logging-view/src/components/normal/ComparisonChart.tsx new file mode 100644 index 000000000..ce4c655ee --- /dev/null +++ b/frontend/logging-view/src/components/normal/ComparisonChart.tsx @@ -0,0 +1,260 @@ +import * as echarts from "echarts"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { decimateLTTB } from "../../lib/plotStudio/decimate"; +import { axisKey } from "../../lib/normal/units"; +import { + TIME_DIVISORS, + type ComparisonSignal, + type TimeDomain, + type TimeRange, + type TimeUnit, +} from "../../types/normal"; + +interface TooltipPoint { + seriesIndex: number; + data?: number[]; + axisValue?: number; +} + +interface Props { + signals: ComparisonSignal[]; + domain: TimeDomain; + range: TimeRange; + timeUnit: TimeUnit; + normalized: boolean; + isDarkMode: boolean; + syncGroup: string; + onRangeChange: (range: TimeRange) => void; +} + +const escapeHtml = (value: string) => + value.replace(/&/g, "&").replace(//g, ">"); + +export default function ComparisonChart({ + signals, + domain, + range, + timeUnit, + normalized, + isDarkMode, + syncGroup, + onRangeChange, +}: Props) { + const elementRef = useRef(null); + const chartRef = useRef(null); + const rangeRef = useRef(range); + const onRangeChangeRef = useRef(onRangeChange); + const [width, setWidth] = useState(0); + + useEffect(() => { + onRangeChangeRef.current = onRangeChange; + }, [onRangeChange]); + + const prepared = useMemo( + () => + signals.map((signal) => { + let min = Infinity; + let max = -Infinity; + for (const value of signal.data.value) { + min = Math.min(min, value); + max = Math.max(max, value); + } + const sampled = decimateLTTB( + signal.data.time, + signal.data.value, + 8_000, + ); + return Array.from(sampled.time, (time, index) => { + const raw = sampled.value[index]; + return [ + (time - domain.startMs) / TIME_DIVISORS[timeUnit], + normalized + ? max === min + ? 50 + : ((raw - min) / (max - min)) * 100 + : raw, + raw, + ]; + }); + }), + [signals, domain.startMs, timeUnit, normalized], + ); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + const chart = echarts.init(element); + chartRef.current = chart; + chart.group = syncGroup; + echarts.connect(syncGroup); + const updateRange = () => { + const zoom = (chart.getOption() as { dataZoom?: TimeRange[] }) + .dataZoom?.[0]; + if (zoom && Number.isFinite(zoom.start) && Number.isFinite(zoom.end)) { + onRangeChangeRef.current({ start: zoom.start, end: zoom.end }); + } + }; + chart.on("datazoom", updateRange); + const observer = new ResizeObserver(() => { + setWidth(element.clientWidth); + chart.resize(); + }); + observer.observe(element); + return () => { + observer.disconnect(); + chart.off("datazoom", updateRange); + chart.dispose(); + chartRef.current = null; + }; + }, [syncGroup]); + + useEffect(() => { + const chart = chartRef.current; + if (!chart || signals.length === 0) return; + const text = isDarkMode ? "#d5d8df" : "#374151"; + const muted = isDarkMode ? "#9ba3b2" : "#6b7280"; + const grid = isDarkMode ? "#3a404b" : "#e6e9ef"; + const units = normalized + ? ["Relative (%)"] + : [...new Set(signals.map(axisKey))]; + const axes = units.map((unit, index) => { + const signal = signals.find((item) => axisKey(item) === unit); + return { + type: "value" as const, + name: normalized ? "Relative (%)" : signal?.discrete ? "State" : unit, + nameLocation: "middle" as const, + nameGap: 48, + nameTextStyle: { color: muted, fontSize: 11 }, + position: index === 0 ? ("left" as const) : ("right" as const), + min: normalized ? 0 : undefined, + max: normalized ? 100 : undefined, + scale: !normalized, + minInterval: signal?.discrete ? 1 : undefined, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: muted, + fontSize: 10, + hideOverlap: true, + formatter: + signal?.enumLabels && !normalized + ? (value: number) => signal.enumLabels?.[value] ?? String(value) + : undefined, + }, + splitLine: { + show: index === 0, + lineStyle: { color: grid, type: "dashed" as const }, + }, + }; + }); + chart.setOption( + { + backgroundColor: "transparent", + animation: false, + textStyle: { color: text }, + grid: { + left: width < 420 ? 62 : 72, + right: axes.length > 1 ? 72 : 22, + top: 22, + bottom: 54, + }, + xAxis: { + type: "value", + min: 0, + max: Math.max( + 1 / TIME_DIVISORS[timeUnit], + (domain.endMs - domain.startMs) / TIME_DIVISORS[timeUnit], + ), + name: `Time (${timeUnit})`, + nameLocation: "middle", + nameGap: 30, + nameTextStyle: { color: muted, fontSize: 11 }, + axisLine: { lineStyle: { color: grid } }, + axisTick: { show: false }, + axisLabel: { color: muted, hideOverlap: true, fontSize: 10 }, + splitLine: { lineStyle: { color: grid, opacity: 0.6 } }, + axisPointer: { + show: true, + label: { show: false }, + lineStyle: { color: muted, type: "dashed" }, + }, + }, + yAxis: axes, + tooltip: { + trigger: "axis", + confine: true, + backgroundColor: isDarkMode + ? "rgba(34,39,48,0.97)" + : "rgba(255,255,255,0.98)", + borderColor: grid, + textStyle: { color: text, fontSize: 12 }, + formatter: (raw: unknown) => { + const params = (Array.isArray(raw) ? raw : [raw]) as TooltipPoint[]; + const time = Number( + params[0]?.data?.[0] ?? params[0]?.axisValue ?? 0, + ); + const rows = params + .map((param) => { + const signal = signals[param.seriesIndex]; + if (!signal) return ""; + const value = Number(param.data?.[2] ?? param.data?.[1]); + if (!Number.isFinite(value)) return ""; + const state = signal.discrete + ? signal.enumLabels?.[Math.round(value)] + : undefined; + const shown = + state ?? + `${value.toLocaleString(undefined, { maximumFractionDigits: 4 })}${signal.units ? ` ${signal.units}` : ""}`; + return `
● ${escapeHtml(signal.board)} / ${escapeHtml(signal.name)} ${escapeHtml(shown)}
`; + }) + .join(""); + return `${time.toLocaleString(undefined, { maximumFractionDigits: 3 })} ${timeUnit}${rows}`; + }, + }, + dataZoom: [ + { + type: "inside", + xAxisIndex: 0, + ...rangeRef.current, + filterMode: "none", + zoomOnMouseWheel: true, + moveOnMouseMove: true, + moveOnMouseWheel: false, + }, + ], + series: signals.map((signal, index) => ({ + id: signal.id, + name: `${signal.board} / ${signal.name}`, + type: "line", + yAxisIndex: normalized ? 0 : units.indexOf(axisKey(signal)), + data: prepared[index], + showSymbol: signal.data.time.length < 20, + symbolSize: 5, + step: signal.discrete ? "end" : false, + lineStyle: { color: signal.color, width: 1.8 }, + itemStyle: { color: signal.color }, + emphasis: { focus: "series" }, + progressive: 5000, + })), + }, + true, + ); + chart.resize(); + }, [signals, prepared, domain, timeUnit, normalized, isDarkMode, width]); + + useEffect(() => { + rangeRef.current = range; + chartRef.current?.dispatchAction( + { type: "dataZoom", ...range }, + { silent: true }, + ); + }, [range]); + + return ( +
+ ); +} diff --git a/frontend/logging-view/src/components/sidebar/AppSidebar.tsx b/frontend/logging-view/src/components/sidebar/AppSidebar.tsx index b8339a684..ed7798b08 100644 --- a/frontend/logging-view/src/components/sidebar/AppSidebar.tsx +++ b/frontend/logging-view/src/components/sidebar/AppSidebar.tsx @@ -9,6 +9,7 @@ import { SidebarHeader, SidebarSeparator, } from "@workspace/ui/components"; +import { useLocation } from "react-router"; import { useStore } from "../../store/store"; import FolderPickerGroup from "./FolderPickerGroup"; import OrdersTimelineControl from "./OrdersTimelineControl"; @@ -19,13 +20,14 @@ import ThemeToggleItem from "./ThemeToggleItem"; const AppSidebar = ({ ...props }: React.ComponentProps) => { const hasLoadedSession = useStore((s) => s.folderName !== null); + const isSimpleMode = useLocation().pathname.startsWith("/simple"); return ( {/* Fixed — always visible regardless of scroll position */} - + {isSimpleMode && } diff --git a/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx b/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx index f1dd697ed..4e03df66e 100644 --- a/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx +++ b/frontend/logging-view/src/components/simple/sidebar/PlotsSection.tsx @@ -23,7 +23,7 @@ import { TooltipContent, TooltipTrigger, } from "@workspace/ui/components"; -import { Activity, ChevronDown, Eye, EyeOff, GripVertical, Plus, Send, Trash2, X } from "@workspace/ui/icons"; +import { ChevronDown, Eye, EyeOff, GripVertical, Plus, Send, Trash2, X } from "@workspace/ui/icons"; import { cn } from "@workspace/ui/lib"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -70,6 +70,7 @@ export default function PlotsSection() { const [assigning, setAssigning] = useState>(new Set()); // Drag-to-reorder: id of the plot currently being dragged const [draggingId, setDraggingId] = useState(null); + const [collapsedPlots, setCollapsedPlots] = useState>(new Set()); // Order lists can be long, so let each plot keep its own collapsed state. const [collapsedOrderSections, setCollapsedOrderSections] = useState>(new Set()); @@ -132,12 +133,27 @@ export default function PlotsSection() { draggable onDragStart={() => setDraggingId(plot.id)} onDragEnd={() => setDraggingId(null)} - className="from-primary/5 flex cursor-grab items-center gap-2 border-b bg-gradient-to-r to-transparent px-3 py-2 active:cursor-grabbing" + className={cn( + "from-primary/5 flex cursor-grab items-center gap-2 bg-gradient-to-r to-transparent px-3 py-2 active:cursor-grabbing", + !collapsedPlots.has(plot.id) && "border-b", + )} > -
- -
+
+ {!collapsedPlots.has(plot.id) && ( +
{/* Assigned signals — grouped by axis so left/right membership is obvious from list position, not just a small per-row dropdown. */} {plot.signals.length > 0 && ( @@ -393,6 +411,8 @@ export default function PlotsSection() { triggerClassName="text-muted-foreground h-7 w-full border-dashed text-[11px] shadow-none" />
+
+ )}
))}
diff --git a/frontend/logging-view/src/lib/normal/layout.ts b/frontend/logging-view/src/lib/normal/layout.ts new file mode 100644 index 000000000..196d39a57 --- /dev/null +++ b/frontend/logging-view/src/lib/normal/layout.ts @@ -0,0 +1,125 @@ +import type { ChartLayout, ChartPane, LayoutPreset } from "../../types/normal"; + +function pane(signalIds: string[]): ChartPane { + return { + type: "pane", + id: crypto.randomUUID(), + signalIds, + normalized: false, + }; +} + +function split( + first: ChartLayout, + second: ChartLayout, + direction: "horizontal" | "vertical", +): ChartLayout { + return { type: "split", id: crypto.randomUUID(), direction, first, second }; +} + +export function getPanes(layout: ChartLayout): ChartPane[] { + return layout.type === "pane" + ? [layout] + : [...getPanes(layout.first), ...getPanes(layout.second)]; +} + +export function createLayout(ids: string[], preset: LayoutPreset): ChartLayout { + const count = + preset === "single" + ? 1 + : Math.min(preset === "grid" ? 4 : 2, Math.max(1, ids.length)); + const panes = Array.from({ length: count }, (_, index) => + pane(ids.filter((_, i) => i % count === index)), + ); + if (count === 1) return panes[0]; + if (preset !== "grid" || count === 2) + return split( + panes[0], + panes[1], + preset === "rows" ? "vertical" : "horizontal", + ); + return split( + split(panes[0], panes[1], "horizontal"), + count === 3 ? panes[2] : split(panes[2], panes[3], "horizontal"), + "vertical", + ); +} + +export function updatePane( + layout: ChartLayout, + id: string, + update: (pane: ChartPane) => ChartLayout, +): ChartLayout { + if (layout.type === "pane") return layout.id === id ? update(layout) : layout; + return { + ...layout, + first: updatePane(layout.first, id, update), + second: updatePane(layout.second, id, update), + }; +} + +export function splitPane( + layout: ChartLayout, + id: string, + direction: "horizontal" | "vertical", + selectedIds: string[], +): ChartLayout { + return updatePane(layout, id, (current) => { + const nextId = + selectedIds.find((signalId) => !current.signalIds.includes(signalId)) ?? + current.signalIds[0]; + return split(current, pane(nextId ? [nextId] : []), direction); + }); +} + +export function removePane( + layout: ChartLayout, + id: string, +): ChartLayout | null { + if (layout.type === "pane") return layout.id === id ? null : layout; + const first = removePane(layout.first, id); + const second = removePane(layout.second, id); + return first && second ? { ...layout, first, second } : (first ?? second); +} + +export function reconcileLayout( + layout: ChartLayout, + previousIds: string[], + selectedIds: string[], +): ChartLayout { + let next = layout; + for (const current of getPanes(next)) { + next = updatePane(next, current.id, (node) => ({ + ...node, + signalIds: node.signalIds.filter((id) => selectedIds.includes(id)), + })); + } + for (const id of selectedIds.filter((id) => !previousIds.includes(id))) { + const target = getPanes(next).reduce((least, current) => + current.signalIds.length < least.signalIds.length ? current : least, + ); + next = updatePane(next, target.id, (node) => ({ + ...node, + signalIds: [...node.signalIds, id], + })); + } + return next; +} + +export function minimumLayoutSize(layout: ChartLayout): { + width: number; + height: number; +} { + if (layout.type === "pane") return { width: 320, height: 270 }; + const first = minimumLayoutSize(layout.first); + const second = minimumLayoutSize(layout.second); + return layout.direction === "horizontal" + ? { + width: first.width + second.width + 10, + height: Math.max(first.height, second.height), + } + : { + width: Math.max(first.width, second.width), + height: first.height + second.height + 10, + }; +} diff --git a/frontend/logging-view/src/lib/normal/units.ts b/frontend/logging-view/src/lib/normal/units.ts new file mode 100644 index 000000000..3e0ef74d5 --- /dev/null +++ b/frontend/logging-view/src/lib/normal/units.ts @@ -0,0 +1,5 @@ +import type { ComparisonSignal } from "../../types/normal"; + +export function axisKey(signal: ComparisonSignal): string { + return signal.discrete ? `state:${signal.id}` : signal.units || "Value"; +} diff --git a/frontend/logging-view/src/lib/plotStudio/csv.ts b/frontend/logging-view/src/lib/plotStudio/csv.ts index 973b4a23e..073777ef1 100644 --- a/frontend/logging-view/src/lib/plotStudio/csv.ts +++ b/frontend/logging-view/src/lib/plotStudio/csv.ts @@ -35,12 +35,13 @@ function encodeTextValue(raw: string, enumValues: string[] | undefined, seen: Ma /** * Parse a 4-column CSV produced by the backend logger. * Expected column order: timestamp, board, backend, value. - * Time is normalized to start at 0 and converted to ms using `timeUnit`. + * Time is converted to ms using `timeUnit`; by default each signal starts at + * zero, while comparison views can preserve the original session timestamps. * `enumValues` — the ADJ measurement's state names, when known — lets an * enum/bool signal logged as text ("Idle", "true") be recovered as the * matching numeric code instead of being silently dropped by parseFloat. */ -export function parseCSV(text: string, timeUnit = "ms", enumValues?: string[]): SeriesData { +export function parseCSV(text: string, timeUnit = "ms", enumValues?: string[], normalizeTime = true): SeriesData { const toMs = TIME_UNIT_TO_MS[timeUnit] ?? 1; const lines = text.trim().split("\n"); const time = new Float64Array(lines.length); @@ -65,7 +66,7 @@ export function parseCSV(text: string, timeUnit = "ms", enumValues?: string[]): const t = time.subarray(0, count); const val = value.subarray(0, count); - if (count > 0) { + if (normalizeTime && count > 0) { const startTime = t[0]; for (let i = 0; i < count; i++) t[i] -= startTime; } @@ -78,7 +79,7 @@ export function parseCSV(text: string, timeUnit = "ms", enumValues?: string[]): * hundreds of thousands of rows, and reading + parsing that synchronously * (as parseCSV does) would freeze the tab for the whole duration. */ -export async function parseCSVInWorker(file: DroppedFile, timeUnit = "ms", enumValues?: string[]): Promise { +export async function parseCSVInWorker(file: DroppedFile, timeUnit = "ms", enumValues?: string[], normalizeTime = true): Promise { // Read the text on the main thread and hand the worker a plain string — // directory-drop sessions store synthetic DroppedFile objects (a closure // over the real File, not the File itself), and a function property like @@ -93,6 +94,6 @@ export async function parseCSVInWorker(file: DroppedFile, timeUnit = "ms", enumV resolve({ time: new Float64Array(timeBuffer, 0, count), value: new Float64Array(valueBuffer, 0, count) }); }; worker.onerror = (err) => { worker.terminate(); reject(err); }; - worker.postMessage({ text, timeUnit, enumValues }); + worker.postMessage({ text, timeUnit, enumValues, normalizeTime }); }); } diff --git a/frontend/logging-view/src/lib/plotStudio/csv.worker.ts b/frontend/logging-view/src/lib/plotStudio/csv.worker.ts index e162d3f16..5bc22b7d1 100644 --- a/frontend/logging-view/src/lib/plotStudio/csv.worker.ts +++ b/frontend/logging-view/src/lib/plotStudio/csv.worker.ts @@ -3,9 +3,9 @@ // in ./csv.ts — do not import this file directly elsewhere. import { parseCSV } from "./csv"; -self.onmessage = (e: MessageEvent<{ text: string; timeUnit: string; enumValues?: string[] }>) => { - const { text, timeUnit, enumValues } = e.data; - const { time, value } = parseCSV(text, timeUnit, enumValues); +self.onmessage = (e: MessageEvent<{ text: string; timeUnit: string; enumValues?: string[]; normalizeTime?: boolean }>) => { + const { text, timeUnit, enumValues, normalizeTime = true } = e.data; + const { time, value } = parseCSV(text, timeUnit, enumValues, normalizeTime); const timeBuffer = time.buffer; const valueBuffer = value.buffer; (self as unknown as Worker).postMessage( diff --git a/frontend/logging-view/src/pages/NormalPage.tsx b/frontend/logging-view/src/pages/NormalPage.tsx index 983c9c877..ab8859624 100644 --- a/frontend/logging-view/src/pages/NormalPage.tsx +++ b/frontend/logging-view/src/pages/NormalPage.tsx @@ -1,30 +1,211 @@ -// Normal mode: full-featured log analysis view. -// Placeholder — UI to be implemented. -import swLogoBlack from "@workspace/ui/outreach/main/software_black.png?inline"; -import swLogoWhite from "@workspace/ui/outreach/main/software_white.png?inline"; +// Normal mode is the exploration workspace: sidebar selections become linked +// ECharts views. Simple mode keeps its separate plot studio and PDF workflow. +import { Button } from "@workspace/ui/components"; +import { Activity, X } from "@workspace/ui/icons"; +import { useEffect, useMemo, useState } from "react"; +import AnalysisWorkspace from "../components/normal/AnalysisWorkspace"; +import { parseCSVInWorker } from "../lib/plotStudio/csv"; +import { traceColor } from "../lib/plotStudio/palette"; +import { + getEnumLabels, + getSignalName, + getSignalUnits, + isDiscreteMeasurement, +} from "../lib/plotStudio/units"; import { useStore } from "../store/store"; +import type { ComparisonSignal } from "../types/normal"; +import type { SeriesData } from "../types/plotStudio"; -const NormalPage = () => { +function EmptyView({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
+
+
+ +
+

{title}

+

+ {description} +

+
+
+ ); +} + +function SessionComparison() { + const folderName = useStore((s) => s.folderName)!; + const sessionFiles = useStore((s) => s.sessionFiles); + const settings = useStore((s) => s.settings); + const adjData = useStore((s) => s.adjData); + const selectedSeries = useStore((s) => s.selectedSeries); + const clearSelectedSeries = useStore((s) => s.clearSelectedSeries); const isDarkMode = useStore((s) => s.isDarkMode); + const [, refresh] = useState(0); + + const selectedIds = useMemo( + () => Object.keys(selectedSeries).filter((id) => selectedSeries[id]), + [selectedSeries], + ); + // Cache belongs to this exact session. Re-selecting a signal keeps its + // parsed data; opening another session creates an empty cache. + const cache = useMemo( + () => ({ + files: sessionFiles, + data: new Map(), + errors: new Map(), + }), + [sessionFiles], + ); + + useEffect(() => { + let cancelled = false; + const loadVisible = async () => { + for (const id of selectedIds) { + if (cancelled) return; + if (cache.data.has(id) || cache.errors.has(id)) continue; + try { + const slash = id.indexOf("/"); + const file = cache.files.get( + `${folderName}/data/${id.slice(0, slash)}/${id.slice(slash + 1)}.csv`, + ); + if (!file) throw new Error("CSV file not found in this session"); + const data = await parseCSVInWorker( + file, + settings?.time_unit ?? "ms", + getEnumLabels(adjData, id), + false, + ); + if (cancelled) return; + if (data.time.length === 0) + throw new Error("CSV contains no plottable samples"); + cache.data.set(id, data); + } catch (error) { + if (cancelled) return; + cache.errors.set( + id, + error instanceof Error + ? error.message + : "Could not read this signal", + ); + } finally { + if (!cancelled) { + refresh((value) => value + 1); + } + } + } + }; + void loadVisible(); + return () => { + cancelled = true; + }; + }, [selectedIds, cache, folderName, settings?.time_unit, adjData]); + + const signals: ComparisonSignal[] = selectedIds.flatMap((id) => { + const data = cache.data.get(id); + if (!data) return []; + const measId = id.slice(id.indexOf("/") + 1); + return [ + { + id, + name: getSignalName(adjData, id) ?? measId, + board: id.slice(0, id.indexOf("/")), + units: getSignalUnits(adjData, id), + enumLabels: getEnumLabels(adjData, id), + discrete: isDiscreteMeasurement(adjData, id), + color: traceColor(selectedIds.indexOf(id)), + data, + }, + ]; + }); + const errors = selectedIds.flatMap((id) => + cache.errors.has(id) ? [`${id}: ${cache.errors.get(id)}`] : [], + ); + const pendingCount = selectedIds.length - signals.length - errors.length; return ( -
-
-

Normal mode

-

- Still under development — use Simple mode for now. -

+
+
+
+

+ Signal workspace +

+

+ {folderName} · {selectedIds.length} selected + {pendingCount > 0 + ? ` · loading ${pendingCount}…` + : " · shared session time axis"} +

+
+ {selectedIds.length > 0 && ( + + )}
-
- ;) - sw logo -
+ ) : ( + <> + {errors.length > 0 && ( +
+ {errors.map((message) => ( +

+ {message} +

+ ))} +
+ )} + {signals.length > 0 ? ( + + ) : ( + 0 ? "Loading signals…" : "No samples available" + } + description={ + pendingCount > 0 + ? "Preparing the selected measurements for comparison." + : "Choose another measurement from the left sidebar." + } + /> + )} + + )}
); -}; +} -export default NormalPage; +export default function NormalPage() { + const folderName = useStore((s) => s.folderName); + if (!folderName) { + return ( + + ); + } + return ; +} diff --git a/frontend/logging-view/src/types/normal.ts b/frontend/logging-view/src/types/normal.ts new file mode 100644 index 000000000..2d0bfb08d --- /dev/null +++ b/frontend/logging-view/src/types/normal.ts @@ -0,0 +1,47 @@ +import type { SeriesData } from "./plotStudio"; + +export interface ComparisonSignal { + id: string; + board: string; + name: string; + units?: string; + enumLabels?: string[]; + discrete: boolean; + color: string; + data: SeriesData; +} + +export type TimeUnit = "ms" | "s" | "min"; +export const TIME_DIVISORS: Record = { + ms: 1, + s: 1_000, + min: 60_000, +}; + +export interface TimeRange { + start: number; + end: number; +} + +export interface TimeDomain { + startMs: number; + endMs: number; +} + +export interface ChartPane { + type: "pane"; + id: string; + signalIds: string[]; + normalized: boolean; +} + +export interface ChartSplit { + type: "split"; + id: string; + direction: "horizontal" | "vertical"; + first: ChartLayout; + second: ChartLayout; +} + +export type ChartLayout = ChartPane | ChartSplit; +export type LayoutPreset = "single" | "columns" | "rows" | "grid"; From a91eca7d5e445c7caea57d635143f7ad32a6d13c Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 18:58:19 +0200 Subject: [PATCH 7/7] feat(logging-view, normal): general enhancements --- .../components/normal/AnalysisWorkspace.tsx | 110 ++++++++++++-- .../src/components/normal/ComparisonChart.tsx | 56 ++++++- .../components/normal/TimeWindowControls.tsx | 143 ++++++++++++++++++ .../components/normal/WindowStatistics.tsx | 139 +++++++++++++++++ .../logging-view/src/layout/AppLayout.tsx | 2 +- .../logging-view/src/lib/normal/statistics.ts | 48 ++++++ .../logging-view/src/pages/NormalPage.tsx | 5 +- frontend/logging-view/src/types/normal.ts | 1 + 8 files changed, 486 insertions(+), 18 deletions(-) create mode 100644 frontend/logging-view/src/components/normal/TimeWindowControls.tsx create mode 100644 frontend/logging-view/src/components/normal/WindowStatistics.tsx create mode 100644 frontend/logging-view/src/lib/normal/statistics.ts diff --git a/frontend/logging-view/src/components/normal/AnalysisWorkspace.tsx b/frontend/logging-view/src/components/normal/AnalysisWorkspace.tsx index b9194de86..974f47880 100644 --- a/frontend/logging-view/src/components/normal/AnalysisWorkspace.tsx +++ b/frontend/logging-view/src/components/normal/AnalysisWorkspace.tsx @@ -12,6 +12,9 @@ import { import { Columns, Plus, Settings2, X } from "@workspace/ui/icons"; import * as echarts from "echarts"; import { + ChartNoAxesCombined, + Eye, + EyeOff, Grid2X2, Link2, Maximize2, @@ -43,6 +46,8 @@ import { type TimeUnit, } from "../../types/normal"; import ComparisonChart from "./ComparisonChart"; +import TimeWindowControls from "./TimeWindowControls"; +import WindowStatistics from "./WindowStatistics"; const MAX_PANES = 8; const PRESETS = [ @@ -70,7 +75,9 @@ interface PaneProps { onFocus: (id: string) => void; onChange: ( id: string, - changes: Partial>, + changes: Partial< + Pick + >, ) => void; onUnitChange: (id: string, unit: string) => void; } @@ -98,7 +105,18 @@ function ComparisonPane({ () => allSignals.filter((signal) => node.signalIds.includes(signal.id)), [allSignals, node.signalIds], ); - const tooManyUnits = new Set(signals.map(axisKey)).size > 2; + const [signalQuery, setSignalQuery] = useState(""); + const visibleSignals = useMemo( + () => + signals.filter((signal) => !node.hiddenSignalIds?.includes(signal.id)), + [signals, node.hiddenSignalIds], + ); + const filteredSignals = allSignals.filter((signal) => + `${signal.id} ${signal.name} ${signal.units ?? ""}` + .toLowerCase() + .includes(signalQuery.trim().toLowerCase()), + ); + const tooManyUnits = new Set(visibleSignals.map(axisKey)).size > 2; const normalized = node.normalized || tooManyUnits; const title = signals.length === 1 ? signals[0].name : "Comparison"; @@ -160,8 +178,22 @@ function ComparisonPane({ Choose signals to compare in this panel.

+
+ setSignalQuery(event.target.value)} + placeholder="Search signals, boards or units…" + aria-label={`Search signals for chart ${index + 1}`} + className="h-8 text-xs" + /> +
- {allSignals.map((signal) => { + {filteredSignals.length === 0 && ( +

+ No matching signals. +

+ )} + {filteredSignals.map((signal) => { const checked = node.signalIds.includes(signal.id); return (
onChange(node.id, { + hiddenSignalIds: node.hiddenSignalIds?.filter( + (id) => id !== signal.id, + ), signalIds: next ? [...node.signalIds, signal.id] : node.signalIds.filter( @@ -259,10 +294,20 @@ function ComparisonPane({
{signals.map((signal) => ( - + onChange(node.id, { + hiddenSignalIds: node.hiddenSignalIds?.includes(signal.id) + ? node.hiddenSignalIds.filter((id) => id !== signal.id) + : [...(node.hiddenSignalIds ?? []), signal.id], + }) + } > {signal.board} / {signal.name} + {node.hiddenSignalIds?.includes(signal.id) && ( + + )} {signal.units && ( {signal.units} )} - + ))} {normalized && ( Relative % )}
- {signals.length > 0 ? ( + {visibleSignals.length > 0 ? ( + ) : signals.length > 0 ? ( +
+ +

All traces in this panel are hidden.

+ +
) : (
{node.signalIds.length @@ -390,6 +450,7 @@ export default function AnalysisWorkspace({ tree: createLayout(selectedIds, "grid"), preset: "grid" as LayoutPreset | null, })); + const [showStatistics, setShowStatistics] = useState(false); const [focusedId, setFocusedId] = useState(null); const [units, setUnits] = useState>({}); const [timeUnit, setTimeUnit] = useState("s"); @@ -447,7 +508,9 @@ export default function AnalysisWorkspace({ const changePane = useCallback( ( id: string, - changes: Partial>, + changes: Partial< + Pick + >, ) => { setLayoutState((current) => ({ ...current, @@ -530,7 +593,18 @@ export default function AnalysisWorkspace({ Linked time
-
+
+ + ))} + {timeUnit} + +
+ + +
+ {error && ( + + )} + + ); +} diff --git a/frontend/logging-view/src/components/normal/WindowStatistics.tsx b/frontend/logging-view/src/components/normal/WindowStatistics.tsx new file mode 100644 index 000000000..63b05ae23 --- /dev/null +++ b/frontend/logging-view/src/components/normal/WindowStatistics.tsx @@ -0,0 +1,139 @@ +import { useMemo } from "react"; +import { summarizeWindow } from "../../lib/normal/statistics"; +import type { + ComparisonSignal, + TimeDomain, + TimeRange, +} from "../../types/normal"; + +const format = (value: number) => + value.toLocaleString(undefined, { maximumSignificantDigits: 6 }); + +export default function WindowStatistics({ + signals, + domain, + range, +}: { + signals: ComparisonSignal[]; + domain: TimeDomain; + range: TimeRange; +}) { + const rows = useMemo(() => { + const duration = domain.endMs - domain.startMs; + const start = domain.startMs + (duration * range.start) / 100; + const end = domain.startMs + (duration * range.end) / 100; + return signals.map((signal) => ({ + signal, + stats: summarizeWindow(signal.data, start, end), + })); + }, [signals, domain, range]); + + return ( +
+
+

Window statistics

+

+ Original values · Recorded samples only · Average is sample-weighted +

+
+
+ + + + {[ + "Signal", + "Unit", + "Samples", + "Min", + "Max", + "Average", + "Std dev", + "First → last", + "Changes", + ].map((label, index) => ( + + ))} + + + + {rows.map(({ signal, stats }) => { + const state = (value: number) => + signal.discrete + ? (signal.enumLabels?.[value] ?? format(value)) + : format(value); + return ( + + + + + {stats ? ( + <> + + + + + + + + ) : ( + + )} + + ); + })} + +
+ {label} +
+ + + + + {signal.board} /{" "} + + {signal.name} + + + + {signal.discrete ? "State" : signal.units || "—"} + + {(stats?.count ?? 0).toLocaleString()} + + {signal.discrete ? "—" : format(stats.min)} + + {signal.discrete ? "—" : format(stats.max)} + + {signal.discrete ? "—" : format(stats.mean)} + + {signal.discrete ? "—" : format(stats.std)} + + {state(stats.first)} → {state(stats.last)} + + {signal.discrete ? stats.changes.toLocaleString() : "—"} + + No samples in this window +
+
+
+ ); +} diff --git a/frontend/logging-view/src/layout/AppLayout.tsx b/frontend/logging-view/src/layout/AppLayout.tsx index 32c29ccb7..4ff1b77f6 100644 --- a/frontend/logging-view/src/layout/AppLayout.tsx +++ b/frontend/logging-view/src/layout/AppLayout.tsx @@ -35,7 +35,7 @@ const AppLayout = ({ children }: AppLayoutProps) => {
- +
{children}
diff --git a/frontend/logging-view/src/lib/normal/statistics.ts b/frontend/logging-view/src/lib/normal/statistics.ts new file mode 100644 index 000000000..e6f057ad2 --- /dev/null +++ b/frontend/logging-view/src/lib/normal/statistics.ts @@ -0,0 +1,48 @@ +import type { SeriesData } from "../../types/plotStudio"; +import { lowerBound, upperBound } from "../plotStudio/range"; + +/** Summarize recorded samples within an inclusive window, without interpolation. */ +export function summarizeWindow( + data: SeriesData, + startMs: number, + endMs: number, +) { + const first = lowerBound(data.time, startMs); + const end = upperBound(data.time, endMs); + let count = 0; + let min = Infinity; + let max = -Infinity; + let mean = 0; + let m2 = 0; + let firstValue = 0; + let lastValue = 0; + let changes = 0; + + for (let i = first; i < end; i++) { + const value = data.value[i]; + if (!Number.isFinite(value)) continue; + if (count === 0) firstValue = value; + else if (value !== lastValue) changes++; + lastValue = value; + count++; + min = Math.min(min, value); + max = Math.max(max, value); + // Welford's method avoids cancellation for signals with large offsets. + const delta = value - mean; + mean += delta / count; + m2 += delta * (value - mean); + } + + return count === 0 + ? null + : { + count, + min, + max, + mean, + std: Math.sqrt(Math.max(0, m2 / count)), + first: firstValue, + last: lastValue, + changes, + }; +} diff --git a/frontend/logging-view/src/pages/NormalPage.tsx b/frontend/logging-view/src/pages/NormalPage.tsx index ab8859624..835cce185 100644 --- a/frontend/logging-view/src/pages/NormalPage.tsx +++ b/frontend/logging-view/src/pages/NormalPage.tsx @@ -58,6 +58,7 @@ function SessionComparison() { () => ({ files: sessionFiles, data: new Map(), + colors: new Map(), errors: new Map(), }), [sessionFiles], @@ -68,6 +69,8 @@ function SessionComparison() { const loadVisible = async () => { for (const id of selectedIds) { if (cancelled) return; + if (!cache.colors.has(id)) + cache.colors.set(id, traceColor(cache.colors.size)); if (cache.data.has(id) || cache.errors.has(id)) continue; try { const slash = id.indexOf("/"); @@ -118,7 +121,7 @@ function SessionComparison() { units: getSignalUnits(adjData, id), enumLabels: getEnumLabels(adjData, id), discrete: isDiscreteMeasurement(adjData, id), - color: traceColor(selectedIds.indexOf(id)), + color: cache.colors.get(id)!, data, }, ]; diff --git a/frontend/logging-view/src/types/normal.ts b/frontend/logging-view/src/types/normal.ts index 2d0bfb08d..84cac0805 100644 --- a/frontend/logging-view/src/types/normal.ts +++ b/frontend/logging-view/src/types/normal.ts @@ -33,6 +33,7 @@ export interface ChartPane { id: string; signalIds: string[]; normalized: boolean; + hiddenSignalIds?: string[]; } export interface ChartSplit {