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
- {/* 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"}
+
+
+
);
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. */