diff --git a/apps/visr/src/components/ControlsDrawer.tsx b/apps/visr/src/components/ControlsDrawer.tsx
index 3e6731f0..4d029124 100644
--- a/apps/visr/src/components/ControlsDrawer.tsx
+++ b/apps/visr/src/components/ControlsDrawer.tsx
@@ -7,9 +7,16 @@ interface ControlsDrawerProps {
open: boolean;
onToggle: () => void;
controls: ReactNode | ReactNode[];
+ /** Shown in the header, so it stays visible when the drawer is closed */
+ status?: ReactNode;
}
-function ControlsDrawer({ open, onToggle, controls }: ControlsDrawerProps) {
+function ControlsDrawer({
+ open,
+ onToggle,
+ controls,
+ status,
+}: ControlsDrawerProps) {
const items = Children.toArray(controls);
return (
@@ -27,12 +34,23 @@ function ControlsDrawer({ open, onToggle, controls }: ControlsDrawerProps) {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
+ gap: 2,
px: 2,
height: 80,
flexShrink: 0,
}}
>
Controls
+
+ {status}
+
= {
+ darks_flats: "Darks/flats",
+};
+
+/** "Fly scan", "Darks/flats", or plain "Scan" */
+function scanLabel({ scanType, planName }: ScanStatus): string {
+ if (scanType) {
+ return `${scanType.charAt(0).toUpperCase()}${scanType.slice(1)} scan`;
+ }
+ if (planName) return PLAN_LABELS[planName] ?? planName;
+ return "Scan";
+}
+
+/** 0:07, 1:42, 1:02:03 */
+function formatDuration(ms: number): string {
+ const total = Math.max(0, Math.floor(ms / 1000));
+ const hours = Math.floor(total / 3600);
+ const minutes = Math.floor((total % 3600) / 60);
+ const seconds = String(total % 60).padStart(2, "0");
+ return hours > 0
+ ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}`
+ : `${minutes}:${seconds}`;
+}
+
+/** The current time, ticking each second while `active` */
+function useNow(active: boolean): number {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ if (!active) return;
+ setNow(Date.now());
+ const timer = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(timer);
+ }, [active]);
+ return now;
+}
+
+type Line = {
+ text: string;
+ colour: string;
+ /** Will change on its own: shown with a spinner */
+ busy?: boolean;
+ /** Fades out after a while: nothing more to say */
+ fades?: boolean;
+ /** Shown in full on hover, for text cut short */
+ detail?: string;
+};
+
+function describe(
+ status: ScanStatus,
+ lastPoints: LastPointsStatus | null,
+ now: number,
+): Line {
+ const label = scanLabel(status);
+ const { startedAt, endedAt } = status;
+ switch (status.phase) {
+ case "running":
+ return {
+ text:
+ startedAt !== undefined
+ ? `${label} running · ${formatDuration(now - startedAt)}`
+ : `${label} running`,
+ colour: "text.secondary",
+ busy: true,
+ };
+ case "complete": {
+ if (lastPoints === "loading") {
+ return {
+ text: `${label} complete, loading the last points…`,
+ colour: "text.secondary",
+ busy: true,
+ };
+ }
+ if (lastPoints === "missing") {
+ return {
+ text: `${label} complete. Some of the last points could not be loaded`,
+ colour: "warning.main",
+ };
+ }
+ const took =
+ startedAt !== undefined && endedAt !== undefined
+ ? ` · ${formatDuration(endedAt - startedAt)}`
+ : "";
+ return {
+ text: `${label} complete${took}`,
+ colour: "text.secondary",
+ fades: true,
+ };
+ }
+ case "aborted":
+ return { text: `${label} aborted`, colour: "text.secondary" };
+ case "failed":
+ return {
+ text: status.error
+ ? `${label} failed: ${status.error}`
+ : `${label} failed`,
+ colour: "error.main",
+ detail: status.error,
+ };
+ }
+}
+
+/** "Complete" fades out after this long; problems stay */
+const COMPLETE_SHOWN_MS = 3000;
+const FADE_MS = 1000;
+
+/** One line of status; remounted when it changes, so a fade starts afresh */
+function StatusText({ line }: { line: Line }) {
+ const [shown, setShown] = useState(true);
+ useEffect(() => {
+ if (!line.fades) return;
+ const timer = setTimeout(() => setShown(false), COMPLETE_SHOWN_MS);
+ return () => clearTimeout(timer);
+ }, [line.fades]);
+ return (
+
+
+ {line.busy && (
+
+ )}
+
+
+ {line.text}
+
+
+
+
+ );
+}
+
+interface ScanStatusLineProps {
+ status: ScanStatus | null;
+ /** The plots' last points, for a view that polls for them after the scan */
+ lastPoints?: LastPointsStatus | null;
+}
+
+/** The latest scan's status in one quiet line */
+export function ScanStatusLine({
+ status,
+ lastPoints = null,
+}: ScanStatusLineProps) {
+ const now = useNow(status?.phase === "running");
+ if (!status) return null;
+ const line = describe(status, lastPoints, now);
+ // the key changes with the state, not the ticking time, so only a new state
+ // restarts the fade
+ const key = `${status.uuid}-${status.phase}-${lastPoints}-${line.busy}`;
+ return (
+
+
+
+ );
+}
+
+export default ScanStatusLine;
diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx
new file mode 100644
index 00000000..695aa9bf
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx
@@ -0,0 +1,123 @@
+import {
+ Box,
+ FormControlLabel,
+ Radio,
+ RadioGroup,
+ Tooltip,
+ Typography,
+} from "@mui/material";
+import { DataSource } from "./dataSource";
+import {
+ POSITIONS,
+ READBACK_ONLY_HELP,
+ SHAPES,
+ type Positions,
+ type Shape,
+} from "./plotKinds";
+
+interface Props {
+ onSetDataSource: (event: React.ChangeEvent) => void;
+ dataSource: DataSource;
+ positions: Positions;
+ onSetPositions: (positions: Positions) => void;
+ /** The shape for readbacks, kept while the setpoint grid is shown */
+ shape: Shape;
+ onSetShape: (shape: Shape) => void;
+}
+
+export default function Controls({
+ onSetDataSource,
+ dataSource,
+ positions,
+ onSetPositions,
+ shape,
+ onSetShape,
+}: Props) {
+ const onGrid = positions === "setpoints";
+ return (
+
+
+ Plots
+
+
+
+
+ Data Source
+
+
+ }
+ label="Camera"
+ />
+ }
+ label="Diodes"
+ />
+
+
+
+
+
+ Positions
+
+ onSetPositions(event.target.value as Positions)}
+ >
+ {POSITIONS.map(({ value, label, help }) => (
+
+ }
+ label={label}
+ />
+
+ ))}
+
+
+
+
+
+ Plot Type
+
+ onSetShape(event.target.value as Shape)}
+ >
+ {SHAPES.map(({ value, label, help }) => {
+ const disabled = onGrid && value !== "rectangles";
+ return (
+
+ {/* a span, so the tooltip still shows on a disabled option */}
+
+ }
+ label={label}
+ disabled={disabled}
+ />
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx
index bdc2b66e..a6a4c092 100644
--- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx
+++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx
@@ -1,8 +1,25 @@
-import { Box, Typography } from "@mui/material";
+import { Box, Typography, useColorScheme } from "@mui/material";
import { ImagePlot } from "@diamondlightsource/davidia";
import { ReactGridLayout, useContainerWidth } from "react-grid-layout";
-import { useLayoutEffect, useState, type ComponentProps } from "react";
-import { useSpectroscopyData } from "./useSpectroscopyData";
+import {
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useState,
+ type ComponentProps,
+} from "react";
+import {
+ DEFAULT_STRIPES,
+ stripesOn,
+ useSpectroscopyData,
+ type DataChannels,
+ type PlotKind,
+ type Rgb,
+ type StripeColours,
+} from "./useSpectroscopyData";
+import { beamWarning } from "./plotKinds";
+import { DataSource } from "./dataSource";
+import type { LastPointsStatus } from "../ScanStatusLine";
const CHANNELS = [
{ key: "red", label: "Red channel" },
@@ -14,21 +31,136 @@ type ChannelKey = (typeof CHANNELS)[number]["key"];
type PlotValues = ComponentProps["values"];
export type SpectroscopyData = Partial>;
+// ---------------------------------------------------------------------------
+// Data source -> channel mapping
+// ---------------------------------------------------------------------------
+
+const SOURCE_CHANNELS: Record<
+ DataSource,
+ Record
+> = {
+ [DataSource.Camera]: {
+ red: "cameraRed",
+ green: "cameraGreen",
+ blue: "cameraBlue",
+ },
+ [DataSource.Diodes]: {
+ red: "diodesRed",
+ green: "diodesGreen",
+ blue: "diodesBlue",
+ },
+};
+
+function selectChannels(
+ data: DataChannels | null | undefined,
+ dataSource: DataSource,
+): SpectroscopyData {
+ if (!data) return {};
+ const mapping = SOURCE_CHANNELS[dataSource];
+ return {
+ red: data[mapping.red],
+ green: data[mapping.green],
+ blue: data[mapping.blue],
+ };
+}
+
+// ---------------------------------------------------------------------------
+// A remark about the plots themselves, in one quiet line under them
+// ---------------------------------------------------------------------------
+
+function PlotNote({ note }: { note: string | null }) {
+ return (
+
+ {note && (
+
+ {note}
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Empty-cell stripes in the colours of the page behind the plots
+// ---------------------------------------------------------------------------
+
+/** The first opaque background colour at or above `element`, if any */
+function backgroundBehind(element: HTMLElement | null): Rgb | null {
+ for (let el = element; el; el = el.parentElement) {
+ const parts = getComputedStyle(el).backgroundColor.match(/[\d.]+/g);
+ if (parts && parts.length >= 3 && (parts.length < 4 || +parts[3] > 0)) {
+ return [+parts[0], +parts[1], +parts[2]];
+ }
+ }
+ return null;
+}
+
+/**
+ * Stripes matching the background behind `element`, read again when the
+ * colour scheme changes (after the browser has applied it)
+ */
+function useStripesBehind(
+ element: React.RefObject,
+): StripeColours {
+ const { mode, systemMode } = useColorScheme();
+ const scheme = mode === "system" ? systemMode : mode;
+ const [background, setBackground] = useState(null);
+ useEffect(() => {
+ const frame = requestAnimationFrame(() => {
+ const found = backgroundBehind(element.current);
+ if (found) setBackground(found.join(","));
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [element, scheme]);
+ return useMemo(
+ () =>
+ background
+ ? stripesOn(background.split(",").map(Number) as unknown as Rgb)
+ : DEFAULT_STRIPES,
+ [background],
+ );
+}
+
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface SpectroscopyPlotsProps {
+ dataSource: DataSource;
+ plotKind: PlotKind;
expanded: boolean;
plotAspectRatio: number | "auto" | "equal";
+ /** Told whether the last points are in, for the scan status */
+ onLastPointsChange?: (lastPoints: LastPointsStatus | null) => void;
}
function SpectroscopyPlots({
+ dataSource,
+ plotKind,
expanded,
plotAspectRatio,
+ onLastPointsChange,
}: SpectroscopyPlotsProps) {
- const { data: channels } = useSpectroscopyData();
const { width, containerRef, mounted } = useContainerWidth();
+ const stripes = useStripesBehind(
+ containerRef as React.RefObject,
+ );
+ const { data, lastPoints } = useSpectroscopyData(plotKind, stripes);
+ useEffect(() => {
+ onLastPointsChange?.(lastPoints);
+ }, [lastPoints, onLastPointsChange]);
+ const channels = useMemo(
+ () => selectChannels(data, dataSource),
+ [data, dataSource],
+ );
// -------------------------------------------------------------------------
// Grid sizing
@@ -80,15 +212,19 @@ function SpectroscopyPlots({
// -------------------------------------------------------------------------
// Views
// -------------------------------------------------------------------------
- const views = CHANNELS.map(({ key }) => (
-
- ));
+ const views = CHANNELS.map(({ key }) => {
+ const values = channels[key];
+ if (!values) return null;
+ return (
+
+ );
+ });
const titles = CHANNELS.map(({ label }) => label);
@@ -100,7 +236,6 @@ function SpectroscopyPlots({
key={i}
sx={{
width: "100%",
- // height: "100%",
minHeight: 0,
display: "flex",
flexDirection: "column",
@@ -140,32 +275,44 @@ function SpectroscopyPlots({
// -------------------------------------------------------------------------
return (
}
sx={{
flex: "1 1 0",
+ display: "flex",
+ flexDirection: "column",
width: "100%",
minWidth: 0,
- height: gridHeight,
- minHeight: minimumGridHeight,
- overflow: "visible",
+ minHeight: 0,
}}
>
- {mounted && (
- {}}
- >
- {plots}
-
- )}
+ }
+ sx={{
+ flex: "1 1 0",
+ width: "100%",
+ minWidth: 0,
+ height: gridHeight,
+ minHeight: minimumGridHeight,
+ overflow: "visible",
+ }}
+ >
+ {mounted && (
+ {}}
+ >
+ {plots}
+
+ )}
+
+
);
}
diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx
index 28efb596..510005a8 100644
--- a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx
+++ b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx
@@ -6,7 +6,13 @@ import { useSubmitWorkflow } from "../../hooks/useSubmitWorkflow";
import { visitTextToVisit } from "../../utils/common";
import ControlsDrawer from "../ControlsDrawer";
import { SpectroscopyForm } from "./SpectroscopyForm";
+import SpectroscopyControls from "./SpectroscopyControls";
import { useInstrumentSession } from "@atlas/app-shell";
+import { DataSource } from "./dataSource";
+import { plotKindFor, type Positions, type Shape } from "./plotKinds";
+import { useSharedState } from "../../hooks/useSharedState";
+import { useScanStatus } from "../../hooks/useScanStatus";
+import ScanStatusLine, { type LastPointsStatus } from "../ScanStatusLine";
export type SpectroscopyFormData = {
total_number_of_scan_points: number;
@@ -24,6 +30,22 @@ const PLOT_ASPECT_RATIO = "equal";
function SpectroscopyView() {
const [drawerOpen, setDrawerOpen] = useState(true);
+ const scanStatus = useScanStatus();
+ const [lastPoints, setLastPoints] = useState(null);
+
+ // Shared across all open tabs (and restored on reload)
+ const [dataSource, setDataSource] = useSharedState(
+ "spectroscopy:dataSource",
+ DataSource.Camera,
+ );
+ const [positions, setPositions] = useSharedState(
+ "spectroscopy:positions",
+ "setpoints",
+ );
+ const [shape, setShape] = useSharedState(
+ "spectroscopy:shape",
+ "rectangles",
+ );
// -------------------------------------------------------------------------
// Set off workflow when scan ends
@@ -47,6 +69,9 @@ function SpectroscopyView() {
}
}, [scanEvent, instrumentSession, submitWorkflow]);
+ const handleSetDataSource = (event: React.ChangeEvent) =>
+ setDataSource(event.target.value as DataSource);
+
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
@@ -64,13 +89,28 @@ function SpectroscopyView() {
}}
>
setDrawerOpen(prev => !prev)}
- controls={}
+ status={}
+ controls={[
+ ,
+ ,
+ ]}
/>
);
diff --git a/apps/visr/src/components/spectroscopy/dataSource.ts b/apps/visr/src/components/spectroscopy/dataSource.ts
new file mode 100644
index 00000000..fda01b0f
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/dataSource.ts
@@ -0,0 +1,4 @@
+export enum DataSource {
+ Camera = "camera",
+ Diodes = "diodes",
+}
diff --git a/apps/visr/src/components/spectroscopy/plotKinds.test.ts b/apps/visr/src/components/spectroscopy/plotKinds.test.ts
new file mode 100644
index 00000000..117c4b67
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/plotKinds.test.ts
@@ -0,0 +1,37 @@
+import { beamWarning, plotKindFor } from "./plotKinds";
+
+describe("beamWarning", () => {
+ const step = { x: 0.25, y: 0.25, source: "scan step" } as const;
+
+ it("warns when the beam is only the scan step, for the beam-sized drawings", () => {
+ expect(beamWarning("ovals", step)).toContain("0.25 × 0.25");
+ expect(beamWarning("gaussian", step)).toContain(
+ "not the real beamline footprint",
+ );
+ });
+
+ it("says nothing when the run records its beam size", () => {
+ expect(
+ beamWarning("ovals", { x: 0.1, y: 0.2, source: "metadata" }),
+ ).toBeNull();
+ });
+
+ it("says nothing for drawings that don't use the beam", () => {
+ expect(beamWarning("cells", step)).toBeNull();
+ expect(beamWarning("rectangles", step)).toBeNull();
+ expect(beamWarning("ovals")).toBeNull();
+ });
+});
+
+describe("plotKindFor", () => {
+ it("draws the setpoint grid whatever the shape", () => {
+ expect(plotKindFor("setpoints", "rectangles")).toBe("cells");
+ expect(plotKindFor("setpoints", "ovals")).toBe("cells");
+ });
+
+ it("draws readbacks in the chosen shape", () => {
+ expect(plotKindFor("readbacks", "rectangles")).toBe("rectangles");
+ expect(plotKindFor("readbacks", "gaussian")).toBe("gaussian");
+ expect(plotKindFor("readbacks", "ovals")).toBe("ovals");
+ });
+});
diff --git a/apps/visr/src/components/spectroscopy/plotKinds.ts b/apps/visr/src/components/spectroscopy/plotKinds.ts
new file mode 100644
index 00000000..3cbeca26
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/plotKinds.ts
@@ -0,0 +1,61 @@
+import type { Beam, PlotKind } from "./useSpectroscopyData";
+
+/** Where the points are drawn: the scan's setpoint grid, or where the motors read back */
+export type Positions = "setpoints" | "readbacks";
+
+/** How each point is drawn at its readback position */
+export type Shape = Exclude;
+
+export const POSITIONS: { value: Positions; label: string; help: string }[] = [
+ {
+ value: "setpoints",
+ label: "Setpoints",
+ help: "The grid of the scan's setpoints (from its spec), each cell the mean of the points read back in it",
+ },
+ {
+ value: "readbacks",
+ label: "Readbacks",
+ help: "Each point at the position the motors actually read back",
+ },
+];
+
+export const SHAPES: { value: Shape; label: string; help: string }[] = [
+ {
+ value: "rectangles",
+ label: "Rectangles",
+ help: "A step-sized rectangle on each point",
+ },
+ {
+ value: "gaussian",
+ label: "Gaussian",
+ help: "A beam-sized Gaussian on each point: gaps the beam didn't cover show",
+ },
+ {
+ value: "ovals",
+ label: "Footprints",
+ help: "Each point's beam footprint, overlaps split between neighbours",
+ },
+];
+
+/** Why Gaussian and footprints can't be picked on the setpoint grid */
+export const READBACK_ONLY_HELP = "Drawn at the readback positions only";
+
+/** The drawing for a choice of positions and shape */
+export function plotKindFor(positions: Positions, shape: Shape): PlotKind {
+ return positions === "setpoints" ? "cells" : shape;
+}
+
+/** Say so when the beam size isn't the beamline's: the run didn't record one */
+export function beamWarning(kind: PlotKind, beam?: Beam): string | null {
+ if (
+ (kind !== "gaussian" && kind !== "ovals") ||
+ beam?.source !== "scan step"
+ ) {
+ return null;
+ }
+ const size = (v: number) => Number(v.toPrecision(3));
+ return (
+ `This run doesn't record its beam size, so this is drawn with a beam of ` +
+ `one scan step (${size(beam.x)} × ${size(beam.y)}), not the real beamline footprint.`
+ );
+}
diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts
new file mode 100644
index 00000000..be7c7d05
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts
@@ -0,0 +1,258 @@
+import {
+ binEdgesToAxis,
+ cellPixelsFor,
+ LINE_SPACING,
+ fetchData,
+ orEmptyLike,
+ splitEdges,
+ DEFAULT_STRIPES,
+ stripesOn,
+ toChannels,
+ toRgbNdt,
+} from "./useSpectroscopyData";
+
+/** Every pixel of one cell as [r, g, b] */
+function cell(
+ matrix: (number | null)[][],
+ row: number,
+ col: number,
+ colour: "red" | "green" = "red",
+) {
+ const image = toRgbNdt(matrix, colour);
+ const size = cellPixelsFor(matrix[0].length, matrix.length);
+ const out: number[][] = [];
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ const py = row * size + y;
+ const px = col * size + x;
+ out.push([0, 1, 2].map(ch => image.get(py, px, ch)));
+ }
+ }
+ return out;
+}
+
+/** The single colour a measured cell is filled with */
+function fill(matrix: (number | null)[][], row: number, col: number) {
+ const pixels = cell(matrix, row, col);
+ expect(new Set(pixels.map(p => p.join()))).toHaveLength(1);
+ return pixels[0];
+}
+
+describe("toRgbNdt", () => {
+ it("scales each value over the matrix's range in its own colour", () => {
+ expect([0, 1, 2].map(c => fill([[10, 15, 20]], 0, c))).toEqual([
+ [0, 0, 0],
+ [128, 0, 0],
+ [255, 0, 0],
+ ]);
+ expect(cell([[0, 4]], 0, 1, "green")[0]).toEqual([0, 255, 0]);
+ });
+
+ it("draws cells without a value as the background crossed by thin lines", () => {
+ const empty = cell([[0, null, 10]], 0, 1).map(p => p.join());
+ const [background, line] = DEFAULT_STRIPES.map(c => c.join());
+ expect(new Set(empty)).toEqual(new Set([background, line]));
+ const lines = empty.filter(p => p === line).length;
+ expect(lines / empty.length).toBeCloseTo(1 / LINE_SPACING, 1);
+ expect(fill([[0, null, 10]], 0, 0)).toEqual([0, 0, 0]); // measured dark stays black
+ });
+
+ it("draws a single point, or equal values, at full brightness", () => {
+ expect(fill([[42]], 0, 0)).toEqual([255, 0, 0]);
+ expect(fill([[7, null, 7]], 0, 2)).toEqual([255, 0, 0]);
+ });
+
+ it("is laid out as rows of height x width x RGB, cellPixelsFor per cell", () => {
+ const image = toRgbNdt(
+ [
+ [1, 2, 3],
+ [4, 5, 6],
+ ],
+ "green",
+ );
+ const size = cellPixelsFor(3, 2);
+ expect(image.shape).toEqual([2 * size, 3 * size, 3]);
+ });
+
+ it("draws a missing channel as the empty placeholder", () => {
+ const size = cellPixelsFor(3, 3);
+ expect(toRgbNdt(null, "red").shape).toEqual([3 * size, 3 * size, 3]);
+ });
+});
+
+describe("cellPixelsFor", () => {
+ it("draws a small grid at least MIN_IMAGE_PIXELS across", () => {
+ expect(cellPixelsFor(3, 3)).toBe(80);
+ expect(cellPixelsFor(17, 5)).toBe(15);
+ });
+
+ it("draws a large grid at least CELL_PIXELS per cell", () => {
+ expect(cellPixelsFor(100, 80)).toBe(4);
+ });
+});
+
+describe("stripesOn", () => {
+ it("is the background and darker lines on a light page", () => {
+ expect(stripesOn([246, 246, 249])).toEqual([
+ [246, 246, 249],
+ [198, 198, 201],
+ ]);
+ });
+
+ it("is the background and lighter lines on a dark page", () => {
+ expect(stripesOn([14, 16, 23])).toEqual([
+ [14, 16, 23],
+ [62, 64, 71],
+ ]);
+ });
+
+ it("colours toRgbNdt's empty cells", () => {
+ const stripes = stripesOn([14, 16, 23]);
+ const image = toRgbNdt([[null]], "red", stripes);
+ const colours = new Set();
+ for (let y = 0; y < image.shape[0]; y++) {
+ for (let x = 0; x < image.shape[1]; x++) {
+ colours.add([0, 1, 2].map(ch => image.get(y, x, ch)).join());
+ }
+ }
+ expect([...colours].sort()).toEqual(stripes.map(c => c.join()).sort());
+ });
+});
+
+describe("orEmptyLike", () => {
+ it("keeps a channel that is there", () => {
+ expect(orEmptyLike([[1, 2]], [[3, 4]])).toEqual([[1, 2]]);
+ });
+
+ it("gives a missing channel empty cells the shape of another", () => {
+ expect(
+ orEmptyLike(null, [
+ [1, null],
+ [3, 4],
+ ]),
+ ).toEqual([
+ [null, null],
+ [null, null],
+ ]);
+ });
+});
+
+describe("splitEdges", () => {
+ it("splits each bin into equal parts, keeping the outer edges", () => {
+ const edges = splitEdges([0, 4, 12], 4);
+ expect(edges).toHaveLength(2 * 4 + 1);
+ expect(edges[0]).toBe(0);
+ expect(edges[4]).toBe(4);
+ expect(edges[edges.length - 1]).toBe(12);
+ expect(edges[1] - edges[0]).toBeCloseTo(1);
+ });
+});
+
+describe("binEdgesToAxis", () => {
+ it("gives the plot the edges themselves, one more than the cells", () => {
+ const axis = binEdgesToAxis([-0.5, 0.5, 1.5]);
+ expect(Array.from(axis.data as Float64Array)).toEqual([-0.5, 0.5, 1.5]);
+ expect(axis.shape).toEqual([3]);
+ });
+
+ it("needs at least two edges", () => {
+ expect(() => binEdgesToAxis([1])).toThrow("At least two bin edges");
+ });
+});
+
+describe("fetchData", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ function respond(
+ status: number,
+ headers: Record = {},
+ body = {},
+ ) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ async () => new Response(JSON.stringify(body), { status, headers }),
+ ),
+ );
+ }
+
+ it("returns the binned images", async () => {
+ respond(200, {}, { RedTotal: [[1]] });
+ expect(await fetchData("uid")).toEqual({ RedTotal: [[1]] });
+ });
+
+ it("reports a 503 as not ready yet, waiting as long as Retry-After asks", async () => {
+ respond(503, { "Retry-After": "2" });
+ expect(await fetchData("uid")).toEqual({
+ notReady: true,
+ retryAfterMs: 2000,
+ });
+ });
+
+ it("waits a second after a 503 without Retry-After", async () => {
+ respond(503);
+ expect(await fetchData("uid")).toEqual({
+ notReady: true,
+ retryAfterMs: 1000,
+ });
+ });
+
+ it("still throws for other errors", async () => {
+ respond(500);
+ await expect(fetchData("uid")).rejects.toThrow();
+ });
+});
+
+describe("toChannels of /render's answer", () => {
+ const stripes = stripesOn([246, 246, 249]);
+ const answer = {
+ viewport: { x0: 0, x1: 2, y0: 10, y1: 11, width: 2, height: 1 },
+ channels: {
+ RedTotal: [[0, 4]],
+ GreenTotal: [[1, 1]],
+ BlueTotal: [[null, 2]],
+ DiodesRed: [[3, null]],
+ DiodesGreen: null,
+ DiodesBlue: null,
+ },
+ coverage: [[1, 0.5]],
+ outline: [[0, 1]],
+ beam: { x: 1, y: 1, source: "scan step" as const },
+ };
+ const pixel = (image: ReturnType, x: number) =>
+ [0, 1, 2].map(ch => image.get(0, x, ch));
+
+ it("draws one pixel per value, over the viewport's pixel edges", () => {
+ const channels = toChannels(answer, stripes);
+ expect(channels.cameraRed.shape).toEqual([1, 2, 3]);
+ expect(Array.from(channels.xValues.data as Float64Array)).toEqual([
+ 0, 1, 2,
+ ]);
+ expect(Array.from(channels.yValues.data as Float64Array)).toEqual([10, 11]);
+ expect(channels.beam).toEqual(answer.beam);
+ });
+
+ it("fades partly covered pixels into the stripes", () => {
+ const channels = toChannels({ ...answer, outline: undefined }, stripes);
+ expect(pixel(channels.cameraRed, 0)).toEqual([0, 0, 0]); // fully covered
+ const [r, g] = pixel(channels.cameraRed, 1); // half 255, half its stripe
+ expect(r).toBe(Math.round(0.5 * 255 + 0.5 * stripes[0][0]));
+ expect(g).toBe(Math.round(0.5 * stripes[0][1]));
+ });
+
+ it("draws outlines in white over the pixels, if asked", () => {
+ const image = toRgbNdt([[0, 4]], "red", stripes, {
+ cellPixels: 1,
+ outline: answer.outline,
+ });
+ expect(pixel(image, 0)).toEqual([0, 0, 0]);
+ expect(pixel(image, 1)).toEqual([255, 255, 255]);
+ });
+
+ it("draws a channel the run lacks, and empty pixels, as the empty pattern", () => {
+ const channels = toChannels({ ...answer, outline: undefined }, stripes);
+ expect(pixel(channels.diodesGreen, 0)).toEqual([...stripes[1]]); // on a line
+ expect(pixel(channels.cameraBlue, 0)).toEqual([...stripes[1]]);
+ expect(pixel(channels.diodesGreen, 1)).toEqual([...stripes[0]]); // between lines
+ });
+});
diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts
index 6efefc25..bfa70002 100644
--- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts
+++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts
@@ -1,117 +1,392 @@
-import { type NDT } from "@diamondlightsource/davidia";
+import type { NDT } from "@diamondlightsource/davidia";
import ndarray from "ndarray";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useScanEvents } from "../../hooks/scanEvents/useScanEvents";
+import type { LastPointsStatus } from "../ScanStatusLine";
/** SpectroscopyData wrapped as NDT for ease of Davidia plotting */
export interface DataChannels {
- red: NDT;
- green: NDT;
- blue: NDT;
+ cameraRed: NDT;
+ cameraGreen: NDT;
+ cameraBlue: NDT;
+ diodesRed: NDT;
+ diodesGreen: NDT;
+ diodesBlue: NDT;
xValues: NDT;
yValues: NDT;
+ /** The beam size /render drew with, and where it came from (Gaussian and footprints) */
+ beam?: Beam;
}
+/** A beam size (FWHM, in motor units) and where /render took it from */
+export type Beam = {
+ x: number;
+ y: number;
+ source: "request" | "metadata" | "scan step";
+};
+
/** Return type of `/api/data/binned` */
type SpectroscopyData = {
- RedTotal: number[][];
- GreenTotal: number[][];
- BlueTotal: number[][];
+ RedTotal: (number | null)[][];
+ GreenTotal: (number | null)[][];
+ BlueTotal: (number | null)[][];
+ /** Null for a run without diodes */
+ DiodesRed: (number | null)[][] | null;
+ DiodesGreen: (number | null)[][] | null;
+ DiodesBlue: (number | null)[][] | null;
/** X bin edges with size of the above datasets + 1 */
x_limits: number[];
/** Y bin edges with size of the above datasets + 1 */
y_limits: number[];
};
-type RGBColour = "red" | "green" | "blue" | "gray";
+type Matrix = (number | null)[][];
+
+/** Return type of `/api/data/render` (JSON) */
+type RenderData = {
+ viewport: {
+ x0: number;
+ x1: number;
+ y0: number;
+ y1: number;
+ width: number;
+ height: number;
+ };
+ channels: Record<"RedTotal" | "GreenTotal" | "BlueTotal", Matrix> &
+ /** Null for a run without diodes */
+ Record<"DiodesRed" | "DiodesGreen" | "DiodesBlue", Matrix | null>;
+ coverage: number[][];
+ outline?: number[][];
+ beam?: Beam;
+};
+
+/** How the scan is drawn: /binned's cells, or one of /render's kinds */
+export type PlotKind = "cells" | "rectangles" | "gaussian" | "ovals";
+
+/**
+ * Whether footprints are drawn with /render's white outlines; without them
+ * the footprints meet edge to edge
+ */
+export const DRAW_OUTLINES = false;
+
+/** Longest side, in pixels, of the images /render draws */
+export const RENDER_SIZE = 200;
+
+export type RGBColour = "red" | "green" | "blue" | "gray";
-function toRgbNdt(matrix: (number | null)[][], colour: RGBColour): NDT {
+/**
+ * Cells no point has landed in yet are drawn as the page's background crossed
+ * by thin diagonal lines, which can't be mistaken for a measured value (dark
+ * or not) and run on unbroken across the empty part of the scan. For lines
+ * that thin, each of /binned's cells is drawn as several pixels square, so the
+ * whole grid is at least MIN_IMAGE_PIXELS on its longer side (and at least
+ * CELL_PIXELS per cell); the axes are split to match.
+ */
+export const CELL_PIXELS = 4;
+export const MIN_IMAGE_PIXELS = 240;
+
+/** Pixels between the empty cells' lines, along a row */
+export const LINE_SPACING = 6;
+
+/** Pixels per cell, on each axis, for a grid of width x height cells */
+export function cellPixelsFor(width: number, height: number): number {
+ return Math.max(
+ CELL_PIXELS,
+ Math.ceil(MIN_IMAGE_PIXELS / Math.max(width, height, 1)),
+ );
+}
+
+/** An RGB colour, 0-255 each */
+export type Rgb = readonly [number, number, number];
+
+/** The empty cells' colours: the background, and its lines */
+export type StripeColours = readonly [Rgb, Rgb];
+
+/** How far the lines are from the background, 0-255 per channel */
+export const STRIPE_CONTRAST = 48;
+
+/**
+ * The empty cells' colours on a background: the background itself, and thin
+ * lines a shade darker on a light background, lighter on a dark one
+ */
+export function stripesOn(background: Rgb): StripeColours {
+ const luminance =
+ 0.299 * background[0] + 0.587 * background[1] + 0.114 * background[2];
+ const shift = luminance > 127 ? -STRIPE_CONTRAST : STRIPE_CONTRAST;
+ const shifted = background.map(c =>
+ Math.min(255, Math.max(0, c + shift)),
+ ) as unknown as Rgb;
+ return [background, shifted];
+}
+
+/** The Diamond theme's light background, until the page's own is known */
+export const DEFAULT_STRIPES = stripesOn([246, 246, 249]);
+
+function emptyColour(row: number, col: number, stripes: StripeColours): Rgb {
+ return stripes[(row + col) % LINE_SPACING === 0 ? 1 : 0];
+}
+
+/** Bin edges with each bin split into `parts` equal parts, to match the image */
+export function splitEdges(edges: number[], parts: number): number[] {
+ const out: number[] = [];
+ for (let i = 0; i < edges.length - 1; i++) {
+ for (let k = 0; k < parts; k++) {
+ out.push(edges[i] + ((edges[i + 1] - edges[i]) * k) / parts);
+ }
+ }
+ out.push(edges[edges.length - 1]);
+ return out;
+}
+
+/** How to draw a matrix as an image */
+export type RgbOptions = {
+ /** Pixels per matrix cell on each axis (default cellPixelsFor the matrix; 1 for /render's pixels) */
+ cellPixels?: number;
+ /** How much of each cell is drawn, 0 to 1 (/render): the rest shows the stripes */
+ coverage?: number[][];
+ /** Opacity of white outlines over each cell, 0 to 1 (/render's footprints) */
+ outline?: number[][];
+};
+
+/**
+ * The matrix as an RGB image in one colour, scaled over its own range, each
+ * cell `cellPixels` pixels square. Cells without a value are stripes in the
+ * given colours, and a partly covered cell fades into them. If every value is
+ * the same (a single point, say), they are drawn at full brightness rather
+ * than black.
+ */
+export function toRgbNdt(
+ matrix: (number | null)[][] | null,
+ colour: RGBColour,
+ stripes: StripeColours = DEFAULT_STRIPES,
+ { cellPixels, coverage, outline }: RgbOptions = {},
+): NDT {
if (!matrix?.length || !matrix[0]?.length) {
- return EMPTY_NDT; // skip invalid input
+ return emptyNdt(stripes); // skip invalid input
}
const height = matrix.length;
const width = matrix[0].length;
-
- // Flatten and filter out nulls for normalisation
+ cellPixels ??= cellPixelsFor(width, height);
const flat = matrix.flat();
- const valid = flat.filter((v): v is number => v !== null && !isNaN(v));
- // Avoid crashes when no valid values
- const min = valid.length ? Math.min(...valid) : 0;
- const max = valid.length ? Math.max(...valid) : 1;
- const scale = max > min ? 255 / (max - min) : 1;
+ // the range of the values, ignoring empty cells (a loop: /render's images
+ // are too large to spread into Math.min)
+ let min = Infinity;
+ let max = -Infinity;
+ for (const v of flat) {
+ if (v !== null && !isNaN(v)) {
+ min = Math.min(min, v);
+ max = Math.max(max, v);
+ }
+ }
+ const scale = max > min ? 255 / (max - min) : 0;
+ const lit = { red: [0], green: [1], blue: [2], gray: [0, 1, 2] }[colour];
- const rgb = new Uint8Array(width * height * 3);
+ const W = width * cellPixels;
+ const H = height * cellPixels;
+ const rgb = new Uint8Array(W * H * 3);
- for (let i = 0; i < flat.length; i++) {
- const v = flat[i];
- let scaled = 0;
- if (v !== null && !isNaN(v)) {
- scaled = Math.round((v - min) * scale);
- } // else stays 0 (black)
-
- switch (colour) {
- case "red":
- rgb[i * 3] = scaled;
- break;
- case "green":
- rgb[i * 3 + 1] = scaled;
- break;
- case "blue":
- rgb[i * 3 + 2] = scaled;
- break;
- case "gray":
- rgb[i * 3] = scaled;
- rgb[i * 3 + 1] = scaled;
- rgb[i * 3 + 2] = scaled;
- break;
+ for (let y = 0; y < H; y++) {
+ for (let x = 0; x < W; x++) {
+ const i = (y * W + x) * 3;
+ const r = Math.floor(y / cellPixels);
+ const c = Math.floor(x / cellPixels);
+ const v = flat[r * width + c];
+ const empty = emptyColour(y, x, stripes);
+ const covered = v === null || isNaN(v) ? 0 : (coverage?.[r]?.[c] ?? 1);
+ const scaled = covered && max > min ? (v! - min) * scale : 255;
+ const line = outline?.[r]?.[c] ?? 0;
+ for (let k = 0; k < 3; k++) {
+ const value = lit.includes(k) ? scaled : 0;
+ const out = covered * value + (1 - covered) * empty[k];
+ rgb[i + k] = Math.round(out * (1 - line) + 255 * line);
+ }
}
}
- return ndarray(rgb, [height, width, 3]) as NDT;
+ return ndarray(rgb, [H, W, 3]) as NDT;
+}
+
+/** Placeholder before any data: 3x3 empty cells, matching initialAxes */
+function emptyNdt(stripes: StripeColours): NDT {
+ return toRgbNdt(
+ [
+ [null, null, null],
+ [null, null, null],
+ [null, null, null],
+ ],
+ "gray",
+ stripes,
+ );
+}
+
+/**
+ * A channel's matrix, or for a run without it (null), empty cells the shape of
+ * the camera's, so it still lines up with the axes
+ */
+export function orEmptyLike(
+ matrix: (number | null)[][] | null,
+ like: (number | null)[][],
+): (number | null)[][] {
+ return matrix ?? like.map(row => row.map(() => null));
}
-/** Placeholder empty gray dataset */
-const EMPTY_NDT = toRgbNdt([[0, 0, 0]], "gray");
-/** given array of edges size L, returns the centre points, size L-1, as NDT */
-export function binEdgesToCentrePoints(edges: number[]): NDT {
+/**
+ * Bin edges as plot axis values. The plot reads one more value than there are
+ * pixels as the pixels' edges, so each cell sits exactly between its own edges.
+ * (Given cell centres instead, it reads them as each pixel's leading edge and
+ * shifts the image by half a cell.)
+ */
+export function binEdgesToAxis(edges: number[]): NDT {
if (edges.length < 2) {
throw new Error("At least two bin edges are required");
}
+ return ndarray(Float64Array.from(edges), [edges.length]) as NDT;
+}
- const centres = new Float64Array(edges.length - 1);
+/** /binned or /render answered 503: the data file is behind the catalog, try again shortly */
+export type NotReady = { notReady: true; retryAfterMs: number };
- for (let i = 0; i < centres.length; i++) {
- centres[i] = (edges[i] + edges[i + 1]) / 2;
+/**
+ * The JSON at `url`, or NotReady when the server says the data isn't written
+ * yet (503, waiting as long as its Retry-After asks, 1 s by default). Any
+ * other failure throws.
+ */
+async function fetchJson(url: string): Promise {
+ const resp = await fetch(url);
+ if (resp.status === 503) {
+ const seconds = Number(resp.headers.get("Retry-After"));
+ return {
+ notReady: true,
+ retryAfterMs:
+ (Number.isFinite(seconds) && seconds > 0 ? seconds : 1) * 1000,
+ };
}
+ if (!resp.ok) throw new Error(resp.statusText);
+ return await resp.json(); // here we should use zod
+}
- return ndarray(centres, [centres.length]);
+/** The binned images of a run, or NotReady */
+export function fetchData(uuid: string): Promise {
+ return fetchJson(`/api/data/binned/${uuid}`);
}
-async function fetchData(uuid: string): Promise {
- const url = `/api/data/binned/${uuid}`;
- const resp = await fetch(url);
- if (!resp.ok) throw new Error(resp.statusText);
- return await resp.json(); // here we should use zod
+/** A run drawn by /render over the scan's extent, or NotReady */
+export function fetchRender(
+ uuid: string,
+ kind: Exclude,
+): Promise {
+ return fetchJson(
+ `/api/data/render/${uuid}?kind=${kind}&size=${RENDER_SIZE}`,
+ );
+}
+
+/** Evenly spaced edges from `start` to `stop` for `count` pixels */
+function pixelEdges(start: number, stop: number, count: number): number[] {
+ return Array.from(
+ { length: count + 1 },
+ (_, i) => start + ((stop - start) * i) / count,
+ );
}
+/** Tries for the final image of a scan, when a 503 isn't followed by another poll */
+const FINAL_POLL_ATTEMPTS = 10;
+
+/** What one poll got: the data, a failure, or the wait the server asked for */
+type PollResult = "done" | "error" | { retryAfterMs: number };
+
// initial axes must have min three points...
-const initialAxes = binEdgesToCentrePoints([-0.25, 0.25, 0.5, 0.75]);
+const INITIAL_EDGES = [-0.25, 0.25, 0.5, 0.75];
+const initialAxes = binEdgesToAxis(
+ splitEdges(INITIAL_EDGES, cellPixelsFor(3, 3)),
+);
-export function useSpectroscopyData(): {
+/**
+ * /binned's or /render's answer as plot images, empty cells in the given
+ * stripes. A channel the run lacks (the diodes) is drawn all empty.
+ */
+export function toChannels(
+ answer: SpectroscopyData | RenderData | null,
+ stripes: StripeColours,
+): DataChannels {
+ if (answer && "viewport" in answer) {
+ const v = answer.viewport;
+ const ch = answer.channels;
+ const options = {
+ cellPixels: 1,
+ coverage: answer.coverage,
+ outline: DRAW_OUTLINES ? answer.outline : undefined,
+ };
+ const image = (matrix: Matrix | null, colour: RGBColour) =>
+ toRgbNdt(orEmptyLike(matrix, ch.RedTotal), colour, stripes, options);
+ return {
+ cameraRed: image(ch.RedTotal, "red"),
+ cameraGreen: image(ch.GreenTotal, "green"),
+ cameraBlue: image(ch.BlueTotal, "blue"),
+ diodesRed: image(ch.DiodesRed, "red"),
+ diodesGreen: image(ch.DiodesGreen, "green"),
+ diodesBlue: image(ch.DiodesBlue, "blue"),
+ xValues: binEdgesToAxis(pixelEdges(v.x0, v.x1, v.width)),
+ yValues: binEdgesToAxis(pixelEdges(v.y0, v.y1, v.height)),
+ beam: answer.beam,
+ };
+ }
+ const binned = answer;
+ if (!binned) {
+ const empty = emptyNdt(stripes);
+ return {
+ cameraRed: empty,
+ cameraGreen: empty,
+ cameraBlue: empty,
+ diodesRed: empty,
+ diodesGreen: empty,
+ diodesBlue: empty,
+ xValues: initialAxes,
+ yValues: initialAxes,
+ };
+ }
+ // every channel and both axes split each cell into the same number of pixels
+ const parts = cellPixelsFor(
+ binned.RedTotal[0]?.length ?? 1,
+ binned.RedTotal.length,
+ );
+ const image = (matrix: (number | null)[][] | null, colour: RGBColour) =>
+ toRgbNdt(orEmptyLike(matrix, binned.RedTotal), colour, stripes, {
+ cellPixels: parts,
+ });
+ return {
+ cameraRed: image(binned.RedTotal, "red"),
+ cameraGreen: image(binned.GreenTotal, "green"),
+ cameraBlue: image(binned.BlueTotal, "blue"),
+ diodesRed: image(binned.DiodesRed, "red"),
+ diodesGreen: image(binned.DiodesGreen, "green"),
+ diodesBlue: image(binned.DiodesBlue, "blue"),
+ xValues: binEdgesToAxis(splitEdges(binned.x_limits, parts)),
+ yValues: binEdgesToAxis(splitEdges(binned.y_limits, parts)),
+ };
+}
+
+/**
+ * The scan drawn as `kind`, polled while it runs, with empty cells in
+ * `stripes`; and once it ends, whether its last points are in yet
+ */
+export function useSpectroscopyData(
+ kind: PlotKind = "cells",
+ stripes: StripeColours = DEFAULT_STRIPES,
+): {
data: DataChannels;
running: boolean;
+ lastPoints: LastPointsStatus | null;
} {
const scanEvent = useScanEvents();
const [running, setRunning] = useState(false);
+ const [lastPoints, setLastPoints] = useState(null);
const [uuid, setUuid] = useState(null);
- const [data, setData] = useState({
- red: EMPTY_NDT,
- green: EMPTY_NDT,
- blue: EMPTY_NDT,
- xValues: initialAxes,
- yValues: initialAxes,
- });
+ // kept as the server answered, so a theme change redraws it without a poll
+ const [answer, setAnswer] = useState(
+ null,
+ );
+ const data = useMemo(() => toChannels(answer, stripes), [answer, stripes]);
/** Cached interval id */
const pollInterval = useRef | null>(null);
@@ -123,6 +398,7 @@ export function useSpectroscopyData(): {
if (scanEvent.status === "running") {
setRunning(true);
setUuid(scanEvent.uuid);
+ setLastPoints(null);
} else if (
scanEvent.status === "finished" ||
scanEvent.status === "failed"
@@ -133,40 +409,59 @@ export function useSpectroscopyData(): {
// Poll during scan + once more afterwards
useEffect(() => {
- async function poll() {
- if (!uuid) return;
+ let cancelled = false;
+
+ async function poll(): Promise {
+ if (!uuid) return "error";
try {
- const resp: SpectroscopyData = await fetchData(uuid);
- setData({
- red: toRgbNdt(resp.RedTotal, "red"),
- green: toRgbNdt(resp.GreenTotal, "green"),
- blue: toRgbNdt(resp.BlueTotal, "blue"),
- xValues: binEdgesToCentrePoints(resp.x_limits),
- yValues: binEdgesToCentrePoints(resp.y_limits),
- });
+ const resp = await (kind === "cells"
+ ? fetchData(uuid)
+ : fetchRender(uuid, kind));
+ if (cancelled) return "done"; // the kind or scan changed meanwhile
+ if ("notReady" in resp) return resp; // the next poll tries again
+ setAnswer(resp);
} catch (err) {
console.error("Polling error:", err);
+ return "error";
}
+ return "done";
+ }
+
+ /** The last poll after a scan ends: no later poll will follow a 503, so retry */
+ async function finalPoll() {
+ for (
+ let attempt = 0;
+ attempt < FINAL_POLL_ATTEMPTS && !cancelled;
+ attempt++
+ ) {
+ const result = await poll();
+ if (cancelled) return;
+ if (result === "done") return setLastPoints(null);
+ if (result === "error") return setLastPoints("missing");
+ setLastPoints("loading");
+ await new Promise(resolve => setTimeout(resolve, result.retryAfterMs));
+ }
+ if (!cancelled) setLastPoints("missing");
}
if (running && uuid) {
// start polling
pollInterval.current = setInterval(poll, 500); // 2 Hz
- } else if (!running && pollInterval.current) {
- // poll once more then clear interval
- poll().finally(() => {
- clearInterval(pollInterval.current!);
- pollInterval.current = null;
- });
+ } else if (!running && uuid) {
+ // The scan has ended: poll once more for its last points. (The previous
+ // run of this effect has already cleared the interval, so this can't
+ // depend on the interval still being set.)
+ void finalPoll();
}
return () => {
+ cancelled = true;
if (pollInterval.current) {
clearInterval(pollInterval.current);
pollInterval.current = null;
}
};
- }, [running, uuid]);
+ }, [running, uuid, kind]);
- return { data, running };
+ return { data, running, lastPoints };
}
diff --git a/apps/visr/src/components/tomography/SliceViewer.tsx b/apps/visr/src/components/tomography/SliceViewer.tsx
index 99041244..7ac17054 100644
--- a/apps/visr/src/components/tomography/SliceViewer.tsx
+++ b/apps/visr/src/components/tomography/SliceViewer.tsx
@@ -1,9 +1,15 @@
import { Box } from "@mui/material";
-import { Plane } from "./PlaneEnum";
import { HeatmapPlot } from "@diamondlightsource/davidia";
import ndarray from "ndarray";
import createArrayFromView from "../../utils/createArrayFromView";
+// Not importing Plane in case we move SliceViewer somewhere else as a generic component
+enum Plane {
+ X,
+ Y,
+ Z,
+}
+
interface Props {
volumeData: Uint8Array;
volumeShape: [number, number, number];
diff --git a/apps/visr/src/components/tomography/TomographyControls.tsx b/apps/visr/src/components/tomography/TomographyControls.tsx
index 30f32c62..65598a68 100644
--- a/apps/visr/src/components/tomography/TomographyControls.tsx
+++ b/apps/visr/src/components/tomography/TomographyControls.tsx
@@ -6,7 +6,7 @@ import {
Slider,
Typography,
} from "@mui/material";
-import { Plane } from "./PlaneEnum";
+import { Plane } from "./plane";
interface Props {
onSlide: (event: Event, newValue: number | number[]) => void;
diff --git a/apps/visr/src/components/tomography/TomographyPlots.tsx b/apps/visr/src/components/tomography/TomographyPlots.tsx
index 598d0f3b..b45b54b7 100644
--- a/apps/visr/src/components/tomography/TomographyPlots.tsx
+++ b/apps/visr/src/components/tomography/TomographyPlots.tsx
@@ -3,7 +3,7 @@ import { ReactGridLayout, useContainerWidth } from "react-grid-layout";
import { useLayoutEffect, useState } from "react";
import SliceViewer from "./SliceViewer";
import VolumeRenderer from "./VolumeRenderer";
-import { Plane } from "./PlaneEnum";
+import { Plane } from "./plane";
// ---------------------------------------------------------------------------
// Types
diff --git a/apps/visr/src/components/tomography/TomographyView.tsx b/apps/visr/src/components/tomography/TomographyView.tsx
index 64c832fa..b83374a8 100644
--- a/apps/visr/src/components/tomography/TomographyView.tsx
+++ b/apps/visr/src/components/tomography/TomographyView.tsx
@@ -2,9 +2,12 @@ import { Box } from "@mui/material";
import { useEffect, useState } from "react";
import TomographyControls from "./TomographyControls";
import { TomographyForm } from "./TomographyForm";
-import { Plane } from "./PlaneEnum";
+import { Plane } from "./plane";
import ControlsDrawer from "../ControlsDrawer";
import TomographyPlots from "./TomographyPlots";
+import { useSharedState } from "../../hooks/useSharedState";
+import { useScanStatus } from "../../hooks/useScanStatus";
+import ScanStatusLine from "../ScanStatusLine";
// ---------------------------------------------------------------------------
// Types
@@ -21,12 +24,14 @@ interface Volume {
function TomographyView() {
const [volume, setVolume] = useState(null);
- const [volumeVisible, setVolumeVisible] = useState(true);
// const [revolve, setRevolve] = useState(false);
- const [slice, setSlice] = useState(0);
- const [plane, setPlane] = useState(Plane.Z);
- const [threshold, setThreshold] = useState(38); // 38/255 ≈ 0.15, the renderer's old default
const [drawerOpen, setDrawerOpen] = useState(true);
+ const scanStatus = useScanStatus();
+
+ // Shared across all open tabs (and restored on reload)
+ const [slice, setSlice] = useSharedState("tomography:slice", 0);
+ const [plane, setPlane] = useSharedState("tomography:plane", Plane.Z);
+ const [threshold, setThreshold] = useSharedState("tomography:threshold", 38); // 38/255 ≈ 0.15, the renderer's old default
// -------------------------------------------------------------------------
// Load test volume
@@ -50,60 +55,33 @@ function TomographyView() {
loadTestVolume();
}, []);
- // -------------------------------------------------------------------------
- // Persist values across multiple open tabs
- // -------------------------------------------------------------------------
- useEffect(() => {
- localStorage.setItem("plane", plane.toString());
- localStorage.setItem("volumeVisible", volumeVisible.toString());
- localStorage.setItem("slice", slice.toString());
- }, [plane, volumeVisible, slice]);
-
- useEffect(() => {
- const onReceiveMessage = (e: StorageEvent) => {
- const { key, newValue } = e;
- switch (key) {
- case "plane": {
- setPlane(Number(newValue));
- break;
- }
- case "volumeVisible": {
- setVolumeVisible(Boolean(newValue));
- break;
- }
- case "slice": {
- setSlice(Number(newValue));
- break;
- }
- }
- };
- window.addEventListener("storage", onReceiveMessage);
- return () => {
- window.removeEventListener("storage", onReceiveMessage);
- };
- }, []);
-
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
- const handleSlider = (event: Event, newValue: number | number[]) => {
- const slice = typeof newValue == "number" ? newValue : newValue[0];
- setSlice(slice);
+ const handleSlider = (_event: Event, newValue: number | number[]) => {
+ setSlice(typeof newValue === "number" ? newValue : newValue[0]);
};
const handlePlane = (event: React.ChangeEvent) => {
- const radioValue = Number((event.target as HTMLInputElement).value);
- setPlane(radioValue);
+ setPlane(Number(event.target.value) as Plane);
};
- const handleThreshold = (event: Event, newValue: number | number[]) => {
- setThreshold(typeof newValue == "number" ? newValue : newValue[0]);
+ const handleThreshold = (_event: Event, newValue: number | number[]) => {
+ setThreshold(typeof newValue === "number" ? newValue : newValue[0]);
};
// revolve to be implemented
if (!volume) return ;
+ // Another tab may switch to an axis with fewer slices, so clamp locally
+ const maxSlice: Record = {
+ [Plane.Z]: volume.volumeShape[0] - 1,
+ [Plane.Y]: volume.volumeShape[1] - 1,
+ [Plane.X]: volume.volumeShape[2] - 1,
+ };
+ const clampedSlice = Math.min(slice, maxSlice[plane]);
+
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
@@ -118,9 +96,9 @@ function TomographyView() {
>
setDrawerOpen(prev => !prev)}
+ status={}
controls={[
- ,
+ ,
,
diff --git a/apps/visr/src/components/tomography/PlaneEnum.tsx b/apps/visr/src/components/tomography/plane.tsx
similarity index 100%
rename from apps/visr/src/components/tomography/PlaneEnum.tsx
rename to apps/visr/src/components/tomography/plane.tsx
diff --git a/apps/visr/src/hooks/useScanStatus.ts b/apps/visr/src/hooks/useScanStatus.ts
new file mode 100644
index 00000000..a771c826
--- /dev/null
+++ b/apps/visr/src/hooks/useScanStatus.ts
@@ -0,0 +1,159 @@
+import { useEffect, useRef, useState } from "react";
+import type { TrackableTask } from "@atlas/blueapi";
+import { useBlueapi, useGetWorkerState } from "@atlas/blueapi-query";
+import { useScanEvents } from "./scanEvents";
+
+/** Where the latest scan is, as far as the scan events and blueapi say */
+export type ScanPhase = "running" | "complete" | "failed" | "aborted";
+
+export interface ScanStatus {
+ phase: ScanPhase;
+ /** The scan's uuid, from the scan events */
+ uuid: string;
+ /** The blueapi plan that ran it, once known */
+ planName?: string;
+ /** The plan's `scan_type` parameter ("step" or "fly"), if it has one */
+ scanType?: string;
+ /** When this tab saw the scan start (ms since the epoch); unknown if the tab joined mid-scan */
+ startedAt?: number;
+ /** When this tab saw the scan end */
+ endedAt?: number;
+ /** Blueapi's error for a failed or aborted scan */
+ error?: string;
+}
+
+/** Tries for blueapi to mark the task complete after the scan ends */
+const TASK_SETTLE_ATTEMPTS = 10;
+const TASK_SETTLE_DELAY_MS = 500;
+
+/**
+ * An error that reads as an abort. The UI aborts with the reason "UI
+ * Intervention"; the worker passing through ABORTING is the other sign.
+ */
+const ABORT_PATTERN = /abort|UI Intervention/i;
+
+const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
+
+/** The plan and scan type a task ran */
+function describeTask(
+ task: TrackableTask,
+): Pick {
+ const params = task.task.params as { scan_type?: unknown };
+ return {
+ planName: task.task.name,
+ scanType:
+ typeof params?.scan_type === "string" ? params.scan_type : undefined,
+ };
+}
+
+/** The task's error, if it ended in one */
+function taskError(task: TrackableTask): string | undefined {
+ if (task.errors.length > 0) return task.errors[0];
+ if (task.outcome?.outcome === "error") {
+ return task.outcome.message ?? task.outcome.type ?? "Unknown error";
+ }
+ return undefined;
+}
+
+/**
+ * The latest scan's status, for a status line: the scan events say when it
+ * starts and ends, and blueapi's task says which plan ran it, its scan type,
+ * and why it failed.
+ */
+export function useScanStatus(): ScanStatus | null {
+ const blueapi = useBlueapi();
+ const scanEvent = useScanEvents();
+ const workerState = useGetWorkerState();
+ const [status, setStatus] = useState(null);
+
+ /** The scan the refs below belong to */
+ const scanUuid = useRef(null);
+ /** Its blueapi task, once known */
+ const taskId = useRef(null);
+ /** Whether the worker was seen aborting during it */
+ const sawAbort = useRef(false);
+
+ // An abort can be over before the task records why, so note it as it happens
+ useEffect(() => {
+ if (workerState.data === "ABORTING") sawAbort.current = true;
+ }, [workerState.data]);
+
+ useEffect(() => {
+ if (!scanEvent) return;
+ const { uuid } = scanEvent;
+
+ /** Merge into the status, if it is still this scan's */
+ const update = (changes: Partial) =>
+ setStatus(current =>
+ current?.uuid === uuid ? { ...current, ...changes } : current,
+ );
+
+ /** Find the running task, for its plan and scan type */
+ async function identify() {
+ try {
+ const { task_id } = await blueapi.worker.getActiveTask();
+ if (!task_id || scanUuid.current !== uuid) return;
+ taskId.current = task_id;
+ update(describeTask(await blueapi.tasks.get(task_id)));
+ } catch (err) {
+ console.warn("Couldn't identify the running task:", err);
+ }
+ }
+
+ /** Once blueapi has finished the task, take its error, if any */
+ async function settle(failedEvent: boolean) {
+ const id = taskId.current;
+ if (!id) return;
+ try {
+ for (let attempt = 0; attempt < TASK_SETTLE_ATTEMPTS; attempt++) {
+ const task = await blueapi.tasks.get(id);
+ if (task.is_complete) {
+ const error = taskError(task);
+ const aborted =
+ sawAbort.current ||
+ (error !== undefined && ABORT_PATTERN.test(error));
+ update({
+ ...describeTask(task),
+ error,
+ phase: aborted
+ ? "aborted"
+ : error !== undefined || failedEvent
+ ? "failed"
+ : "complete",
+ });
+ return;
+ }
+ await delay(TASK_SETTLE_DELAY_MS);
+ }
+ } catch (err) {
+ console.warn("Couldn't fetch the finished task:", err);
+ }
+ }
+
+ if (scanEvent.status === "running") {
+ if (scanUuid.current === uuid) return; // the same scan, already shown
+ scanUuid.current = uuid;
+ taskId.current = null;
+ sawAbort.current = workerState.data === "ABORTING";
+ setStatus({ phase: "running", uuid, startedAt: Date.now() });
+ void identify();
+ } else {
+ const failedEvent = scanEvent.status === "failed";
+ if (scanUuid.current !== uuid) {
+ // the tab joined mid-scan: no start time or task to go on
+ scanUuid.current = uuid;
+ taskId.current = null;
+ }
+ setStatus(current => ({
+ ...(current?.uuid === uuid ? current : { uuid }),
+ phase: failedEvent ? "failed" : "complete",
+ endedAt: Date.now(),
+ }));
+ void settle(failedEvent);
+ }
+ // workerState is only read for the starting value of sawAbort
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [scanEvent, blueapi]);
+
+ return status;
+}
diff --git a/apps/visr/src/hooks/useSharedState.ts b/apps/visr/src/hooks/useSharedState.ts
new file mode 100644
index 00000000..6d99f8b1
--- /dev/null
+++ b/apps/visr/src/hooks/useSharedState.ts
@@ -0,0 +1,75 @@
+import { useCallback, useMemo, useSyncExternalStore } from "react";
+
+// The `storage` event only fires in *other* tabs, so same-tab
+// subscribers are notified through this map instead.
+const listeners = new Map void>>();
+
+/**
+ * Like useState, but persisted to localStorage and kept in sync
+ * across every open tab on the same origin.
+ */
+export function useSharedState(key: string, initial: T) {
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === key) onChange();
+ };
+ window.addEventListener("storage", onStorage);
+
+ const set = listeners.get(key) ?? new Set();
+ listeners.set(key, set);
+ set.add(onChange);
+
+ return () => {
+ window.removeEventListener("storage", onStorage);
+ set.delete(onChange);
+ };
+ },
+ [key],
+ );
+
+ const getSnapshot = () => {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+ };
+
+ const raw = useSyncExternalStore(subscribe, getSnapshot, () => null);
+
+ const value = useMemo(() => {
+ if (raw === null) return initial;
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return initial;
+ }
+ // `initial` is only a fallback; re-parse when the stored value changes
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [raw]);
+
+ const setValue = useCallback(
+ (next: T | ((prev: T) => T)) => {
+ let prev = initial;
+ try {
+ const current = localStorage.getItem(key);
+ if (current !== null) prev = JSON.parse(current) as T;
+ } catch {
+ /* fall back to initial */
+ }
+ const resolved =
+ typeof next === "function" ? (next as (p: T) => T)(prev) : next;
+ try {
+ localStorage.setItem(key, JSON.stringify(resolved));
+ } catch {
+ /* storage full or blocked; ignore */
+ }
+ listeners.get(key)?.forEach(l => l());
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [key],
+ );
+
+ return [value, setValue] as const;
+}
diff --git a/packages/blueapi-ui/src/RunPlanButton.tsx b/packages/blueapi-ui/src/RunPlanButton.tsx
index 5096bf41..eeb51aa0 100644
--- a/packages/blueapi-ui/src/RunPlanButton.tsx
+++ b/packages/blueapi-ui/src/RunPlanButton.tsx
@@ -48,9 +48,8 @@ export function RunPlanButton({
setMsg(result.message);
} catch (error) {
setSeverity("error");
- setMsg(
- `Failed to run plan ${name}, see console and blueapi logs for full error.`,
- );
+ const reason = error instanceof Error ? error.message : String(error);
+ setMsg(`Failed to run plan ${name}: ${reason}`);
console.log(`Failed to run plan ${name}.\n Reason: ${error}`);
} finally {
setLoading(false);