From 40c899b0db030dc1120ec9b39100f1c76535ea1c Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 3 Aug 2026 18:22:56 +0200 Subject: [PATCH 1/4] fix(logging-view): resolve four Plot Studio issues from davidpascual05 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Plotly's built-in "Download plot as a PNG" modebar button, which exported the raw dark on-screen canvas with no Hyperloop branding — the header's own "Export PNG" button already produces a properly themed, branded export, so two competing PNG-export paths on the same toolbar was the actual cause of the "logo missing" reports (#601). - Show a toast confirmation when a new plot is added (#602). - Make each plot's name in the right-panel "Plots" list a link that scrolls the corresponding plot into view (#603). - Add a per-plot lock toggle that freezes pan/zoom/scroll-zoom (via Plotly's fixedrange), and disables drag-to-resize, inline rename and signal drops, so the view can't be changed by accident (#604). --- frontend/frontend-kit/ui/src/icons/account.ts | 2 +- .../src/components/PlotAddedToast.tsx | 48 +++++++++ .../components/simple/plots/PlotWrapper.tsx | 99 ++++++++++++++----- .../simple/sidebar/PlotsSection.tsx | 19 +++- .../logging-view/src/layout/AppLayout.tsx | 2 + .../src/lib/plotStudio/plotlyTheme.ts | 18 ++-- .../src/store/slices/plotStudioSlice.ts | 23 ++++- frontend/logging-view/src/types/plotStudio.ts | 3 + 8 files changed, 181 insertions(+), 33 deletions(-) create mode 100644 frontend/logging-view/src/components/PlotAddedToast.tsx diff --git a/frontend/frontend-kit/ui/src/icons/account.ts b/frontend/frontend-kit/ui/src/icons/account.ts index 618e249f8..2c491fa73 100644 --- a/frontend/frontend-kit/ui/src/icons/account.ts +++ b/frontend/frontend-kit/ui/src/icons/account.ts @@ -1 +1 @@ -export { Settings, Settings2, Wrench } from "lucide-react"; +export { Lock, Settings, Settings2, Unlock, Wrench } from "lucide-react"; diff --git a/frontend/logging-view/src/components/PlotAddedToast.tsx b/frontend/logging-view/src/components/PlotAddedToast.tsx new file mode 100644 index 000000000..807de4ef6 --- /dev/null +++ b/frontend/logging-view/src/components/PlotAddedToast.tsx @@ -0,0 +1,48 @@ +// Transient confirmation toast shown when a new plot is added to Plot Studio +// (issue: adding a plot gave no on-screen feedback). Mirrors SessionStatusToast's +// auto-dismiss + manual-dismiss pattern, but anchored bottom-right so it never +// overlaps that top-right session toast. +import { CheckCircle2, X } from "@workspace/ui/icons"; +import { cn } from "@workspace/ui/lib"; +import { useEffect } from "react"; +import { useStore } from "../store/store"; +import { sessionStatusStyles } from "./sidebar/sessionStatusStyles"; + +const PlotAddedToast = () => { + const plotName = useStore((s) => s.plotAddedToast); + const setPlotAddedToast = useStore((s) => s.setPlotAddedToast); + + useEffect(() => { + if (!plotName) return; + const timer = setTimeout(() => setPlotAddedToast(null), 3000); + return () => clearTimeout(timer); + }, [plotName, setPlotAddedToast]); + + if (!plotName) return null; + + return ( +
+
+ + + {plotName} added + + +
+
+ ); +}; + +export default PlotAddedToast; diff --git a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx index b07201f39..303bf9a63 100644 --- a/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx +++ b/frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx @@ -21,9 +21,11 @@ import { Activity, AlertTriangle, ChevronDown, + Lock, Pencil, RefreshCw, Trash2, + Unlock, } from "@workspace/ui/icons"; import { cn } from "@workspace/ui/lib"; import logoIcon from "@workspace/ui/outreach/main/logo_icon.svg?inline"; @@ -50,10 +52,16 @@ const PLOTLY_CONFIG: Partial = { displaylogo: false, scrollZoom: true, showTips: false, - modeBarButtonsToRemove: ["select2d", "lasso2d"], + // Plotly's own built-in "Download plot as a PNG" button is removed — + // it snapshots the raw on-screen (dark-mode) canvas with no branding, + // unlike the header's "Export PNG" button (exportPNG below), which always + // renders in the light/print theme with the Hyperloop logo watermark. + // Keeping both around meant whichever one a user reached for (this one + // sits right in the modebar, next to Autoscale/Reset axes) gave + // inconsistent, sometimes unbranded exports. + modeBarButtonsToRemove: ["select2d", "lasso2d", "toImage"], modeBarButtonsToAdd: ["togglespikelines", "hoverclosest", "hovercompare"], editable: true, - toImageButtonOptions: { format: "png", width: 1200, height: 800, scale: 1 }, }; // Above this point count, decimate before handing points to Plotly (always @@ -134,6 +142,8 @@ const PlotWrapper = forwardRef(({ plot }, re 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 locked = !!plot.locked; const chartRef = useRef(null); const containerRef = useRef(null); @@ -146,13 +156,14 @@ const PlotWrapper = forwardRef(({ plot }, re // Drop target for signals dragged from the left Series sidebar — assignment // itself happens centrally in useSignalDnd's handleDragEnd (AppLayout.tsx). - const { setNodeRef: setDropRef, isOver } = useDroppable({ id: plot.id }); + // Disabled while locked, so a stray drag can't assign a signal by mistake. + const { setNodeRef: setDropRef, isOver } = useDroppable({ id: plot.id, disabled: locked }); // Inline rename const [editingName, setEditingName] = useState(false); const [draftName, setDraftName] = useState(plot.name); - const startRename = () => { setDraftName(plot.name); setEditingName(true); }; + const startRename = () => { if (locked) return; setDraftName(plot.name); setEditingName(true); }; const commitRename = () => { const t = draftName.trim(); if (t && t !== plot.name) renameStudioPlot(plot.id, t); @@ -358,13 +369,22 @@ const PlotWrapper = forwardRef(({ plot }, re const layout = useMemo>( () => hasTimeline - ? buildTimelineLayout({ theme: getPlotlyTheme(isDarkMode), plotId: plot.id, rowLabels: timelineRowLabels }) + ? buildTimelineLayout({ theme: getPlotlyTheme(isDarkMode), plotId: plot.id, rowLabels: timelineRowLabels, locked }) : buildPlotLayout({ theme: getPlotlyTheme(isDarkMode), hasFFT, hasLeftAxis, hasRightAxis, plotId: plot.id, leftUnits: axisUnits.left, rightUnits: axisUnits.right, + locked, }), - [hasLeftAxis, hasRightAxis, hasFFT, hasTimeline, timelineRowLabels, plot.id, axisUnits, isDarkMode], + [hasLeftAxis, hasRightAxis, hasFFT, hasTimeline, timelineRowLabels, plot.id, axisUnits, isDarkMode, locked], + ); + + // editable (click-to-rename title/legend) is the one PLOTLY_CONFIG option + // that isn't axis-scoped (fixedrange, set via `layout` above, already + // blocks pan/zoom/scroll-zoom/box-zoom while locked) — gated separately here. + const plotlyConfig = useMemo>( + () => ({ ...PLOTLY_CONFIG, editable: !locked }), + [locked], ); // Drag-to-resize @@ -373,11 +393,12 @@ const PlotWrapper = forwardRef(({ plot }, re const startHeight = useRef(0); const onResizeMouseDown = useCallback((e: React.MouseEvent) => { + if (locked) return; isResizing.current = true; startY.current = e.clientY; startHeight.current = containerRef.current?.offsetHeight ?? plotHeight; e.preventDefault(); - }, [plotHeight]); + }, [plotHeight, locked]); useEffect(() => { const onMove = (e: MouseEvent) => { @@ -531,10 +552,12 @@ const PlotWrapper = forwardRef(({ plot }, re
{/* Gradient accent strip */} @@ -569,13 +592,15 @@ const PlotWrapper = forwardRef(({ plot }, re type="button" onDoubleClick={startRename} className="group/name flex min-w-0 items-center gap-1.5" - title="Double-click to rename" + title={locked ? undefined : "Double-click to rename"} > {plot.name} - + {!locked && ( + + )} )} @@ -599,8 +624,10 @@ const PlotWrapper = forwardRef(({ plot }, re
- {/* Zoom cluster — only meaningful with traces */} - {hasTraces && ( + {/* 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. */} + {hasTraces && !locked && (
{/* Categorical y-axis in timeline mode isn't zoomable the same way — X (time) still is. */} {!hasTimeline && hasLeftAxis && } @@ -618,6 +645,26 @@ const PlotWrapper = forwardRef(({ plot }, re
)} + + + + + + {locked ? "Locked — view can't be changed by accident. Click to unlock" : "Lock plot"} + + + + + Jump to plot +
); diff --git a/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts b/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts index d637a3454..bf9090448 100644 --- a/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts +++ b/frontend/logging-view/src/lib/plotStudio/plotlyTheme.ts @@ -60,6 +60,10 @@ export interface BuildPlotLayoutParams { // fraction to keep the same absolute pixel gap below the x-axis title — // without this, a shorter canvas collides the legend into the title. exportHeight?: number; + // Freezes every axis (fixedrange) so pan/zoom/scroll-zoom/box-zoom and + // double-click-autoscale are all no-ops — the "lock plot" feature. Hover/ + // tooltips still work. Never set for export figures (always a static image). + locked?: boolean; } // Absolute pixel gap (at the historical 1600px-tall single-plot PNG export) @@ -68,7 +72,7 @@ export interface BuildPlotLayoutParams { const EXPORT_LEGEND_GAP_PX = 190; export function buildPlotLayout({ - theme, hasFFT, hasLeftAxis, hasRightAxis, plotId, leftUnits, rightUnits, fontScale = 1, exportHeight = 1600, + theme, hasFFT, hasLeftAxis, hasRightAxis, plotId, leftUnits, rightUnits, fontScale = 1, exportHeight = 1600, locked = false, }: BuildPlotLayoutParams): Partial { // The live chart's plot area is only a few hundred px tall (resizable, user // controlled), while export renders into a fixed-height canvas — the same @@ -96,7 +100,7 @@ export function buildPlotLayout({ tickfont: { size: 14 * fontScale, color: theme.neutralLineColor }, gridcolor: theme.gridColor, linecolor: theme.neutralLineColor, linewidth: 1.5 * fontScale, mirror: true, ticks: "outside", tickwidth: 1.5 * fontScale, tickcolor: theme.neutralLineColor, color: theme.neutralLineColor, - showline: true, zeroline: false, fixedrange: false, + showline: true, zeroline: false, fixedrange: locked, exponentformat: "power", separatethousands: true, }, yaxis: hasLeftAxis @@ -110,7 +114,7 @@ export function buildPlotLayout({ tickfont: { size: 14 * fontScale, color: theme.neutralLineColor }, gridcolor: theme.gridColor, linecolor: theme.neutralLineColor, linewidth: 1.5 * fontScale, mirror: !hasRightAxis, ticks: "outside", tickwidth: 1.5 * fontScale, tickcolor: theme.neutralLineColor, color: theme.neutralLineColor, - showline: true, zeroline: false, fixedrange: false, + showline: true, zeroline: false, fixedrange: locked, exponentformat: "power", separatethousands: true, } : { @@ -155,7 +159,7 @@ export function buildPlotLayout({ // the left axis's solid grid, so the two scales read as distinct. overlaying: "y", side: "right", gridcolor: theme.gridColor, griddash: "dash", linecolor: theme.neutralLineColor, linewidth: 1.5 * fontScale, ticks: "outside", tickwidth: 1.5 * fontScale, - tickcolor: theme.neutralLineColor, color: theme.neutralLineColor, showline: true, zeroline: false, fixedrange: false, + tickcolor: theme.neutralLineColor, color: theme.neutralLineColor, showline: true, zeroline: false, fixedrange: locked, exponentformat: "power", separatethousands: true, }; } @@ -169,6 +173,8 @@ export interface BuildTimelineLayoutParams { fontScale?: number; // See BuildPlotLayoutParams.exportHeight. exportHeight?: number; + // See BuildPlotLayoutParams.locked. + locked?: boolean; } // Sibling to buildPlotLayout for the "Cronograma" (Gantt) plot mode: one @@ -178,7 +184,7 @@ export interface BuildTimelineLayoutParams { // X title) that folding this into buildPlotLayout would mean more branches // than shared code. export function buildTimelineLayout({ - theme, plotId, rowLabels, fontScale = 1, exportHeight = 1600, + theme, plotId, rowLabels, fontScale = 1, exportHeight = 1600, locked = false, }: BuildTimelineLayoutParams): Partial { const isExport = fontScale !== 1; const marginSide = isExport ? 1.5 : 1; @@ -195,7 +201,7 @@ export function buildTimelineLayout({ tickfont: { size: 14 * fontScale, color: theme.neutralLineColor }, gridcolor: theme.gridColor, linecolor: theme.neutralLineColor, linewidth: 1.5 * fontScale, mirror: true, ticks: "outside", tickwidth: 1.5 * fontScale, tickcolor: theme.neutralLineColor, color: theme.neutralLineColor, - showline: true, zeroline: false, fixedrange: false, + showline: true, zeroline: false, fixedrange: locked, }, yaxis: { type: "category", diff --git a/frontend/logging-view/src/store/slices/plotStudioSlice.ts b/frontend/logging-view/src/store/slices/plotStudioSlice.ts index 2f7cc89b3..b94244a4d 100644 --- a/frontend/logging-view/src/store/slices/plotStudioSlice.ts +++ b/frontend/logging-view/src/store/slices/plotStudioSlice.ts @@ -20,6 +20,10 @@ export interface PlotStudioSlice { // multi-select drag where one CSV is missing/malformed) — not persisted, // cleared by the banner itself after a timeout or on dismiss. signalLoadWarning: string | null; + // Transient confirmation toast, set to the new plot's name whenever + // addStudioPlot runs (whichever of its several call sites triggered it) — + // not persisted, cleared by the toast itself after a timeout or on dismiss. + plotAddedToast: string | null; addStudioFiles: (files: FileSignal[]) => void; removeStudioFile: (name: string) => void; @@ -36,9 +40,11 @@ export interface PlotStudioSlice { removeSignalFromStudioPlot: (plotId: string, signalId: string) => void; updateStudioSignalAxis: (plotId: string, signalId: string, axis: "left" | "right") => void; toggleStudioPlotFFT: (plotId: string) => void; + toggleStudioPlotLocked: (plotId: string) => void; updateStudioSignalColor: (plotId: string, signalId: string, color: string) => void; setStudioFFTSampleRate: (rate: number | null) => void; setSignalLoadWarning: (message: string | null) => void; + setPlotAddedToast: (name: string | null) => void; } export const createPlotStudioSlice: StateCreator = (set) => ({ @@ -51,6 +57,7 @@ export const createPlotStudioSlice: StateCreator = (set) => ({ studioTrCounter: 0, fftSampleRateOverride: null, signalLoadWarning: null, + plotAddedToast: null, addStudioFiles: (files) => set((s) => { @@ -125,9 +132,10 @@ export const createPlotStudioSlice: StateCreator = (set) => ({ set((s) => { const id = `plot_${s.studioPlotCounter}`; newId = id; + const name = `Plot ${s.studioPlots.size + 1}`; const next = new Map(s.studioPlots); - next.set(id, { id, name: `Plot ${next.size + 1}`, signals: [], showFFT: false, nextColorIndex: 0 }); - return { studioPlots: next, studioPlotCounter: s.studioPlotCounter + 1 }; + next.set(id, { id, name, signals: [], showFFT: false, nextColorIndex: 0 }); + return { studioPlots: next, studioPlotCounter: s.studioPlotCounter + 1, plotAddedToast: name }; }); return newId; }, @@ -219,6 +227,15 @@ export const createPlotStudioSlice: StateCreator = (set) => ({ return { studioPlots: next }; }), + toggleStudioPlotLocked: (plotId) => + set((s) => { + const plot = s.studioPlots.get(plotId); + if (!plot) return {}; + const next = new Map(s.studioPlots); + next.set(plotId, { ...plot, locked: !plot.locked }); + return { studioPlots: next }; + }), + updateStudioSignalColor: (plotId, signalId, color) => set((s) => { const plot = s.studioPlots.get(plotId); @@ -236,6 +253,8 @@ export const createPlotStudioSlice: StateCreator = (set) => ({ setStudioFFTSampleRate: (rate) => set({ fftSampleRateOverride: rate }), setSignalLoadWarning: (message) => set({ signalLoadWarning: message }), + + setPlotAddedToast: (name) => set({ plotAddedToast: name }), }); // Helper: resolve a signal (file/operation/transform) from store state diff --git a/frontend/logging-view/src/types/plotStudio.ts b/frontend/logging-view/src/types/plotStudio.ts index 87dd6695d..df4b44f70 100644 --- a/frontend/logging-view/src/types/plotStudio.ts +++ b/frontend/logging-view/src/types/plotStudio.ts @@ -67,6 +67,9 @@ 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 + // 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; } /** Summary statistics for a signal over a time range. */ From c375d8ee76deb73173c83efea216f173d0f05a4a Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 3 Aug 2026 18:55:01 +0200 Subject: [PATCH 2/4] fix(logging-view): make PDF Table of Contents entries clickable links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #603 was about the exported PDF's Table of Contents page, not the app's sidebar plot list — its entries were plain text with no way to jump to the corresponding chart page. Each entry (and its page number) is now an internal jsPDF link to that chart's page, verified against a real export (link annotations with distinct /Dest page targets, one per plot). --- frontend/logging-view/src/lib/pdfExport/pages.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/logging-view/src/lib/pdfExport/pages.ts b/frontend/logging-view/src/lib/pdfExport/pages.ts index 20ffbb5ee..d11a97c63 100644 --- a/frontend/logging-view/src/lib/pdfExport/pages.ts +++ b/frontend/logging-view/src/lib/pdfExport/pages.ts @@ -112,8 +112,14 @@ export function fillTocPage( const lineHeight = 8; entries.forEach((entry, i) => { if (y > CONTENT_BOTTOM) return; // more entries than a single TOC page can hold — rare, clipped - doc.text(`${i + 1}. ${entry.title}`, MARGIN.left, y); - doc.text(String(entry.page), PAGE.width - MARGIN.right, y, { align: "right" }); + // Internal link — jumps straight to that chart's page (entry.page is + // already the 1-based page number textWithLink's pageNumber expects, + // same as getNumberOfPages() in buildPdf.ts). Blue, matching the ADJ + // commit hash link's color below, so it reads as clickable. + doc.setTextColor(37, 99, 235); + doc.textWithLink(`${i + 1}. ${entry.title}`, MARGIN.left, y, { pageNumber: entry.page }); + doc.textWithLink(String(entry.page), PAGE.width - MARGIN.right, y, { pageNumber: entry.page, align: "right" }); + doc.setTextColor(0); y += lineHeight; }); From 1925d5c84d0eaa90adc2623db761d4daa3e6e82b Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 3 Aug 2026 19:00:14 +0200 Subject: [PATCH 3/4] fix(logging-view): clear Plot Studio state when a session is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a session (the X in the Session sidebar group) reset session metadata but left every plot, loaded CSV signal, and composed operation/ transform from that session sitting in the store — dangling references to data that no longer existed. clearSession now also resets studioPlots, studioFiles, studioOperations and studioTransforms. --- frontend/logging-view/src/store/slices/sessionSlice.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/logging-view/src/store/slices/sessionSlice.ts b/frontend/logging-view/src/store/slices/sessionSlice.ts index adc6a6fa7..8a5f87e75 100644 --- a/frontend/logging-view/src/store/slices/sessionSlice.ts +++ b/frontend/logging-view/src/store/slices/sessionSlice.ts @@ -214,6 +214,10 @@ export const createSessionSlice: StateCreator = (se setSessionStatusToast: (status) => set({ sessionStatusToast: status }), + // Closing a session also tears down everything built from it in Plot + // Studio — plots, loaded CSV signals, and composed operations/transforms + // (derived from those same CSVs) — since none of it means anything once + // its source session is gone. clearSession: () => set({ folderName: null, @@ -225,5 +229,9 @@ export const createSessionSlice: StateCreator = (se sessionStatus: null, sessionStatusToast: null, isSessionPanelOpen: true, + studioPlots: new Map(), + studioFiles: new Map(), + studioOperations: new Map(), + studioTransforms: new Map(), }), }); From 80b2bb59b4aef0508576f0311437d1b766a75b41 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 3 Aug 2026 19:02:59 +0200 Subject: [PATCH 4/4] fix(logging-view): move session-loaded toast to bottom-right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was top-right, inconsistent with the newer plot-added toast — moved to the same bottom-right corner/style so both read as one toast stack. --- frontend/logging-view/src/components/SessionStatusToast.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/logging-view/src/components/SessionStatusToast.tsx b/frontend/logging-view/src/components/SessionStatusToast.tsx index 0c7f29667..529c4e396 100644 --- a/frontend/logging-view/src/components/SessionStatusToast.tsx +++ b/frontend/logging-view/src/components/SessionStatusToast.tsx @@ -2,6 +2,8 @@ // Floats above page content so it's visible regardless of which route the user is // on (a session can be opened from the sidebar on any page). Mirrors the auto-dismiss // + manual-dismiss pattern used by the signalLoadWarning banner in PlotStudio.tsx. +// Bottom-right, same corner as PlotAddedToast, so every transient notification +// in the app reads as one consistent toast stack. import { AlertTriangle, X } from "@workspace/ui/icons"; import { cn } from "@workspace/ui/lib"; import { useEffect } from "react"; @@ -25,7 +27,7 @@ const SessionStatusToast = () => { return (