Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/frontend-kit/ui/src/icons/account.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { Settings, Settings2, Wrench } from "lucide-react";
export { Lock, Settings, Settings2, Unlock, Wrench } from "lucide-react";
48 changes: 48 additions & 0 deletions frontend/logging-view/src/components/PlotAddedToast.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={cn(
"fixed bottom-4 right-4 z-40 w-72 rounded-lg border p-3 text-xs shadow-lg",
sessionStatusStyles.ok.badgeClass,
)}
>
<div className="flex items-center gap-2">
<CheckCircle2 className="size-4 shrink-0" />
<span className="flex-1">
<span className="font-medium">{plotName}</span> added
</span>
<button
type="button"
aria-label="Dismiss"
onClick={() => setPlotAddedToast(null)}
className="shrink-0 opacity-70 hover:opacity-100"
>
<X className="size-3.5" />
</button>
</div>
</div>
);
};

export default PlotAddedToast;
4 changes: 3 additions & 1 deletion frontend/logging-view/src/components/SessionStatusToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,7 +27,7 @@ const SessionStatusToast = () => {
return (
<div
className={cn(
"fixed top-4 right-4 z-40 w-80 rounded-lg border p-3 text-xs shadow-lg",
"fixed bottom-4 right-4 z-40 w-80 rounded-lg border p-3 text-xs shadow-lg",
badgeClass,
)}
>
Expand Down
99 changes: 76 additions & 23 deletions frontend/logging-view/src/components/simple/plots/PlotWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -50,10 +52,16 @@ const PLOTLY_CONFIG: Partial<Plotly.Config> = {
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
Expand Down Expand Up @@ -134,6 +142,8 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ 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<PlotlyChartHandle>(null);
const containerRef = useRef<HTMLDivElement>(null);
Expand All @@ -146,13 +156,14 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ 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);
Expand Down Expand Up @@ -358,13 +369,22 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ plot }, re
const layout = useMemo<Partial<Plotly.Layout>>(
() =>
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<Partial<Plotly.Config>>(
() => ({ ...PLOTLY_CONFIG, editable: !locked }),
[locked],
);

// Drag-to-resize
Expand All @@ -373,11 +393,12 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ 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) => {
Expand Down Expand Up @@ -531,10 +552,12 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ plot }, re
<ContextMenu>
<ContextMenuTrigger asChild>
<div
id={plot.id}
ref={setDropRef}
className={cn(
"bg-card overflow-hidden rounded-xl border shadow-md transition-shadow hover:shadow-lg",
isOver && "ring-primary ring-2 ring-offset-2",
locked && "ring-1 ring-amber-500/40",
)}
>
{/* Gradient accent strip */}
Expand Down Expand Up @@ -569,13 +592,15 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ 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"}
>
<span className="text-foreground truncate text-sm font-semibold">{plot.name}</span>
<Pencil
onClick={startRename}
className="text-muted-foreground size-3 shrink-0 cursor-pointer opacity-0 transition-opacity hover:!opacity-100 group-hover/name:opacity-60"
/>
{!locked && (
<Pencil
onClick={startRename}
className="text-muted-foreground size-3 shrink-0 cursor-pointer opacity-0 transition-opacity hover:!opacity-100 group-hover/name:opacity-60"
/>
)}
</button>
)}

Expand All @@ -599,8 +624,10 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ plot }, re
</div>

<div className="ml-auto flex items-center gap-1.5">
{/* 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 && (
<div className="bg-muted/40 flex items-center gap-1 rounded-lg border px-1.5 py-1">
{/* Categorical y-axis in timeline mode isn't zoomable the same way — X (time) still is. */}
{!hasTimeline && hasLeftAxis && <ZoomGroup label="Y◀" axis="y1" onZoom={zoomAxis} />}
Expand All @@ -618,6 +645,26 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ plot }, re
</div>
)}

<Tooltip>
<TooltipTrigger asChild>
<Button
variant={locked ? "default" : "ghost"}
size="icon-xs"
onClick={() => toggleStudioPlotLocked(plot.id)}
aria-label={locked ? `Unlock ${plot.name}` : `Lock ${plot.name}`}
className={cn(
!locked && "text-muted-foreground hover:text-foreground",
locked && "bg-amber-500/90 text-white hover:bg-amber-500",
)}
>
{locked ? <Lock className="size-3.5" /> : <Unlock className="size-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
{locked ? "Locked — view can't be changed by accident. Click to unlock" : "Lock plot"}
</TooltipContent>
</Tooltip>

<Button
variant={showStats ? "default" : "outline"}
size="xs"
Expand Down Expand Up @@ -666,13 +713,15 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ plot }, re
// Chart canvas follows app dark mode; exports stay pinned to the
// light/academic theme regardless (see buildExportFigure above).
<div ref={containerRef} className="relative" style={{ height: plotHeight, backgroundColor: isDarkMode ? "#181818" : "white" }}>
<PlotlyChart ref={chartRef} traces={traces} layout={layout} config={PLOTLY_CONFIG} style={{ height: "100%" }} />
<div
onMouseDown={onResizeMouseDown}
className="hover:bg-primary/10 group absolute inset-x-0 bottom-0 flex h-3 cursor-ns-resize items-center justify-center"
>
<div className="bg-border group-hover:bg-primary/50 h-0.5 w-12 rounded-full transition-colors" />
</div>
<PlotlyChart ref={chartRef} traces={traces} layout={layout} config={plotlyConfig} style={{ height: "100%" }} />
{!locked && (
<div
onMouseDown={onResizeMouseDown}
className="hover:bg-primary/10 group absolute inset-x-0 bottom-0 flex h-3 cursor-ns-resize items-center justify-center"
>
<div className="bg-border group-hover:bg-primary/50 h-0.5 w-12 rounded-full transition-colors" />
</div>
)}
</div>
) : (
<div className="border-muted-foreground/20 bg-muted/20 m-4 mt-1 flex h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed">
Expand All @@ -690,18 +739,22 @@ const PlotWrapper = forwardRef<PlotExportHandle, PlotWrapperProps>(({ plot }, re
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={startRename}>
<ContextMenuItem onClick={startRename} disabled={locked}>
<Pencil className="size-3.5" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleStudioPlotLocked(plot.id)}>
{locked ? <Unlock className="size-3.5" /> : <Lock className="size-3.5" />}
{locked ? "Unlock" : "Lock"}
</ContextMenuItem>
<ContextMenuCheckboxItem checked={plot.showFFT} onCheckedChange={() => toggleStudioPlotFFT(plot.id)}>
Frequency spectrum (FFT)
</ContextMenuCheckboxItem>
<ContextMenuItem onClick={() => setCollapsed((v) => !v)}>
<ChevronDown className={`size-3.5 transition-transform ${collapsed ? "-rotate-90" : ""}`} />
{collapsed ? "Expand" : "Collapse"}
</ContextMenuItem>
<ContextMenuItem onClick={resetZoom} disabled={!hasTraces}>
<ContextMenuItem onClick={resetZoom} disabled={!hasTraces || locked}>
<RefreshCw className="size-3.5" />
Reset Zoom
</ContextMenuItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ export default function PlotsSection() {
const shortName = (id: string) =>
id.includes("/") ? id.split("/").slice(1).join("/") : id.replace(/\.csv$/, "");

// Jump to a plot's card in the main PlotsArea — mirrors PlotWrapper's
// `id={plot.id}` on its outer card so this is a plain in-page anchor.
const scrollToPlot = (plotId: string) => {
document.getElementById(plotId)?.scrollIntoView({ behavior: "smooth", block: "start" });
};

const assign = async (plotId: string, signalId: string) => {
setAssigning((prev) => new Set(prev).add(plotId));
try {
Expand Down Expand Up @@ -125,7 +131,18 @@ export default function PlotsSection() {
<div className="bg-primary/20 flex size-4 shrink-0 items-center justify-center rounded-sm">
<Activity className="text-primary size-3" />
</div>
<span className="text-foreground flex-1 truncate text-xs font-semibold">{plot.name}</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => scrollToPlot(plot.id)}
className="text-foreground hover:text-primary min-w-0 flex-1 truncate text-left text-xs font-semibold hover:underline"
>
{plot.name}
</button>
</TooltipTrigger>
<TooltipContent side="left">Jump to plot</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<label className="text-muted-foreground hover:text-foreground flex shrink-0 cursor-pointer items-center gap-1 text-[10px] transition-colors">
Expand Down
2 changes: 2 additions & 0 deletions frontend/logging-view/src/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DndContext } from "@dnd-kit/core";
import { SidebarInset, SidebarProvider } from "@workspace/ui/components";
import { useEffect, type ReactNode } from "react";
import Header from "../components/header/Header";
import PlotAddedToast from "../components/PlotAddedToast";
import SessionStatusToast from "../components/SessionStatusToast";
import AppSidebar from "../components/sidebar/AppSidebar";
import SignalDragOverlay from "../components/simple/SignalDragOverlay";
Expand Down Expand Up @@ -42,6 +43,7 @@ const AppLayout = ({ children }: AppLayoutProps) => {
<SignalDragOverlay activeIds={activeIds} />
</DndContext>
<SessionStatusToast />
<PlotAddedToast />
</SidebarProvider>
</div>
);
Expand Down
10 changes: 8 additions & 2 deletions frontend/logging-view/src/lib/pdfExport/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down
Loading
Loading