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/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 (
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/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; }); 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/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(), }), }); 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. */