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/App.tsx b/frontend/logging-view/src/App.tsx index df09f26a1..9d4a53356 100644 --- a/frontend/logging-view/src/App.tsx +++ b/frontend/logging-view/src/App.tsx @@ -1,8 +1,7 @@ // Root component. Wraps all pages in the shared AppLayout (sidebar + header). // The active view mode (Normal / Simple) is encoded in the URL so each mode -// can have a completely independent UI. Navigating to "/" redirects to -// "/simple" — Simple (Plot Studio) is the default mode for now because -// Normal mode isn't developed yet; switch this back to "/normal" once it is. +// can have a completely independent UI. Normal is the default analysis view; +// Simple remains the plot studio for publication and PDF export. import { Navigate, Route, Routes } from "react-router"; import AppLayout from "./layout/AppLayout"; import NormalPage from "./pages/NormalPage"; @@ -12,7 +11,7 @@ const App = () => { return ( - } /> + } /> } /> } /> diff --git a/frontend/logging-view/src/components/normal/AnalysisWorkspace.tsx b/frontend/logging-view/src/components/normal/AnalysisWorkspace.tsx new file mode 100644 index 000000000..974f47880 --- /dev/null +++ b/frontend/logging-view/src/components/normal/AnalysisWorkspace.tsx @@ -0,0 +1,707 @@ +import { + Button, + Checkbox, + Input, + Popover, + PopoverContent, + PopoverTrigger, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@workspace/ui/components"; +import { Columns, Plus, Settings2, X } from "@workspace/ui/icons"; +import * as echarts from "echarts"; +import { + ChartNoAxesCombined, + Eye, + EyeOff, + Grid2X2, + Link2, + Maximize2, + Minimize2, + Minus, + RotateCcw, + Rows2, + Square, +} from "lucide-react"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { + createLayout, + getPanes, + minimumLayoutSize, + reconcileLayout, + removePane, + splitPane, + updatePane, +} from "../../lib/normal/layout"; +import { axisKey } from "../../lib/normal/units"; +import { + TIME_DIVISORS, + type ChartLayout, + type ChartPane, + type ComparisonSignal, + type LayoutPreset, + type TimeDomain, + type TimeRange, + type TimeUnit, +} from "../../types/normal"; +import ComparisonChart from "./ComparisonChart"; +import TimeWindowControls from "./TimeWindowControls"; +import WindowStatistics from "./WindowStatistics"; + +const MAX_PANES = 8; +const PRESETS = [ + { value: "single", label: "Single", Icon: Square }, + { value: "columns", label: "Columns", Icon: Columns }, + { value: "rows", label: "Rows", Icon: Rows2 }, + { value: "grid", label: "Grid", Icon: Grid2X2 }, +] as const; + +interface PaneProps { + node: ChartPane; + index: number; + allSignals: ComparisonSignal[]; + domain: TimeDomain; + range: TimeRange; + timeUnit: TimeUnit; + isDarkMode: boolean; + syncGroup: string; + canSplit: boolean; + canClose: boolean; + focused: boolean; + onRangeChange: (range: TimeRange) => void; + onSplit: (id: string, direction: "horizontal" | "vertical") => void; + onClose: (id: string) => void; + onFocus: (id: string) => void; + onChange: ( + id: string, + changes: Partial< + Pick + >, + ) => void; + onUnitChange: (id: string, unit: string) => void; +} + +function ComparisonPane({ + node, + index, + allSignals, + domain, + range, + timeUnit, + isDarkMode, + syncGroup, + canSplit, + canClose, + focused, + onRangeChange, + onSplit, + onClose, + onFocus, + onChange, + onUnitChange, +}: PaneProps) { + const signals = useMemo( + () => allSignals.filter((signal) => node.signalIds.includes(signal.id)), + [allSignals, node.signalIds], + ); + 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"; + + return ( +
+
+ + {String(index + 1).padStart(2, "0")} + +

+ {title} +

+
+ + + + + + + +
+

Signals & units

+

+ 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" + /> +
+
+ {filteredSignals.length === 0 && ( +

+ No matching signals. +

+ )} + {filteredSignals.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) => ( + + ))} + {normalized && ( + Relative % + )} +
+
+ {visibleSignals.length > 0 ? ( + + ) : signals.length > 0 ? ( +
+ +

All traces in this panel are hidden.

+ +
+ ) : ( +
+ {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 [showStatistics, setShowStatistics] = useState(false); + 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< + Pick + >, + ) => { + 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 })) + } + /> +
+
+ {showStatistics && ( + + )} +
+ + 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..4b632297a --- /dev/null +++ b/frontend/logging-view/src/components/normal/ComparisonChart.tsx @@ -0,0 +1,306 @@ +import * as echarts from "echarts"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { decimateLTTB } from "../../lib/plotStudio/decimate"; +import { lowerBound, upperBound } from "../../lib/plotStudio/range"; +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]); + + // Keep normalization relative to the whole recording while sampling only + // the visible interval so zooming reveals samples omitted at wider scales. + const extents = 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); + } + return { min, max }; + }), + [signals], + ); + + const prepared = useMemo( + () => + signals.map((signal, signalIndex) => { + const { min, max } = extents[signalIndex]; + const duration = domain.endMs - domain.startMs; + // Include neighbours to retain lines crossing either viewport edge. + const first = Math.max( + 0, + lowerBound( + signal.data.time, + domain.startMs + (duration * range.start) / 100, + ) - 1, + ); + const last = Math.min( + signal.data.time.length, + upperBound( + signal.data.time, + domain.startMs + (duration * range.end) / 100, + ) + 1, + ); + const sampled = decimateLTTB( + signal.data.time.subarray(first, last), + signal.data.value.subarray(first, last), + 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, + extents, + domain.startMs, + domain.endMs, + range, + 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; + rangeRef.current = range; + 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, + range, + 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/normal/TimeWindowControls.tsx b/frontend/logging-view/src/components/normal/TimeWindowControls.tsx new file mode 100644 index 000000000..eb36dcf21 --- /dev/null +++ b/frontend/logging-view/src/components/normal/TimeWindowControls.tsx @@ -0,0 +1,143 @@ +import { Button, Input } from "@workspace/ui/components"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useId, useState } from "react"; +import type { TimeRange, TimeUnit } from "../../types/normal"; + +export default function TimeWindowControls({ + duration, + range, + timeUnit, + onChange, +}: { + duration: number; + range: TimeRange; + timeUnit: TimeUnit; + onChange: (range: TimeRange) => void; +}) { + const errorId = useId(); + const [draft, setDraft] = useState<{ start: string; end: string } | null>( + null, + ); + const [error, setError] = useState(""); + const values = draft ?? { + start: String(Number(((duration * range.start) / 100).toPrecision(12))), + end: String(Number(((duration * range.end) / 100).toPrecision(12))), + }; + const move = (direction: number) => { + const span = range.end - range.start; + const start = Math.max( + 0, + Math.min(100 - span, range.start + direction * span), + ); + onChange({ start, end: start + span }); + }; + + return ( +
{ + event.preventDefault(); + const start = Number(values.start); + const end = Number(values.end); + if ( + !values.start.trim() || + !values.end.trim() || + !Number.isFinite(start) || + !Number.isFinite(end) || + start < 0 || + end > duration || + start >= end + ) { + setError( + `Enter a start before the end, between 0 and ${duration} ${timeUnit}.`, + ); + return; + } + setError(""); + setDraft(null); + onChange({ + start: (start / duration) * 100, + end: (end / duration) * 100, + }); + }} + > + + Time window + + {(["start", "end"] as const).map((field) => ( + + ))} + {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/components/sidebar/AppSidebar.tsx b/frontend/logging-view/src/components/sidebar/AppSidebar.tsx index 7d2f61a26..ed7798b08 100644 --- a/frontend/logging-view/src/components/sidebar/AppSidebar.tsx +++ b/frontend/logging-view/src/components/sidebar/AppSidebar.tsx @@ -9,33 +9,46 @@ 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"; 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); + const isSimpleMode = useLocation().pathname.startsWith("/simple"); - {/* Scrollable area for series selection */} - - - + return ( + + {/* Fixed — always visible regardless of scroll position */} + + + {isSimpleMode && } + + - -
- - + {/* 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/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx index 303bf9a63..aba0d67d9 100644 --- a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx +++ b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx @@ -35,6 +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 } 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 +71,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 +79,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,6 +179,7 @@ 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); @@ -366,8 +408,57 @@ 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, + // 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: "top" as const, + xshift: 3, + yshift: 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 +467,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 +566,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 +574,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: "top" as const, + xshift: 5, + 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, + }))], + }; + } // 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 @@ -548,6 +663,8 @@ const PlotWrapper = forwardRef(({ plot }, re [plot.signals, resolvedSignals, adjData], ); + const chartSurface = getPlotlyTheme(isDarkMode).paperBg; + return ( @@ -564,7 +681,7 @@ const PlotWrapper = forwardRef(({ plot }, re
{/* Header */} -
+
+ {!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 && ( @@ -278,6 +303,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")}>
+
+ )}
))}
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/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/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/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/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/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/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/lib/plotStudio/plotlyTheme.ts b/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts index bf9090448..874907354 100644 --- a/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts +++ b/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts @@ -32,11 +32,11 @@ export function getPlotlyTheme(isDarkMode: boolean): PlotlyThemeColors { }; } return { - paperBg: "#181818", plotBg: "#181818", - fontColor: "#f9fafb", gridColor: "#404040", neutralLineColor: "#f9fafb", - hoverBg: "rgba(24,24,24,0.95)", hoverBorder: "#404040", hoverFontColor: "#f9fafb", - legendBg: "rgba(24,24,24,0.9)", legendBorder: "#404040", legendFontColor: "#f9fafb", - modebar: { bgcolor: "rgba(24,24,24,0)", color: "#b5b5b5", activecolor: "#ff7f24" }, + paperBg: "#242424", plotBg: "#2b2b2b", + fontColor: "#f5f5f5", gridColor: "#484848", neutralLineColor: "#d4d4d4", + hoverBg: "rgba(43,43,43,0.97)", hoverBorder: "#5a5a5a", hoverFontColor: "#f5f5f5", + legendBg: "rgba(36,36,36,0.96)", legendBorder: "#5a5a5a", legendFontColor: "#f5f5f5", + modebar: { bgcolor: "rgba(36,36,36,0)", color: "#c4c4c4", activecolor: "#ff7f24" }, }; } diff --git a/frontend/logging-view/src/pages/NormalPage.tsx b/frontend/logging-view/src/pages/NormalPage.tsx index 983c9c877..835cce185 100644 --- a/frontend/logging-view/src/pages/NormalPage.tsx +++ b/frontend/logging-view/src/pages/NormalPage.tsx @@ -1,30 +1,214 @@ -// 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(), + colors: new Map(), + errors: new Map(), + }), + [sessionFiles], + ); + + useEffect(() => { + let cancelled = false; + 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("/"); + 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: cache.colors.get(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/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/normal.ts b/frontend/logging-view/src/types/normal.ts new file mode 100644 index 000000000..84cac0805 --- /dev/null +++ b/frontend/logging-view/src/types/normal.ts @@ -0,0 +1,48 @@ +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; + hiddenSignalIds?: string[]; +} + +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"; 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; +} 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