with no height,
// which breaks the size chain. This gives it one.
"& > div": { height: "100%" },
+ // The toolbar only carries RegionSelectMode, which draws nothing
+ "& > div > div:first-of-type:not(:last-child)": { display: "none" },
}}
>
}
+ selectionOptions={{ rectangle: 1 }}
+ selectionsListener={onSelection}
values={channels[key]}
/>
)),
- [channels, plotAspectRatio],
+ [channels, plotAspectRatio, picking, onSelection],
);
return (
- {plots}
+
+ value && setKind(value)}
+ aria-label="How the scan is drawn"
+ >
+ {PLOT_KINDS.map(({ kind, label, help }) => (
+
+ {label}
+
+ ))}
+
+
+ setPicking(p => !p)}
+ >
+
+ Pick region
+
+
+ {region && (
+
+ setRegion(undefined)}
+ />
+
+ )}
+
+ {progress && (
+
+ {percent !== null && (
+
+ )}
+
+ {describeProgress(progress)}
+
+ {describeOffset(progress) && (
+
+ · {describeOffset(progress)}
+
+ )}
+
+ )}
+ {beamWarning(kind, channels.beam) && (
+
+ {beamWarning(kind, channels.beam)}
+
+ )}
+
+ {plots}
+
);
}
diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx
index e1cdbd7b..2f986e1e 100644
--- a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx
+++ b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx
@@ -1,11 +1,12 @@
import { Box } from "@mui/material";
import SpectroscopyPlots from "./SpectroscopyPlots";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useScanEvents } from "../../hooks/scanEvents";
import { useSubmitWorkflow } from "../../hooks/useSubmitWorkflow";
import { useInstrumentSession } from "@atlas/app-shell";
import { visitTextToVisit } from "../../utils/common";
import ControlsDrawer from "../ControlsDrawer";
+import HeightHandle from "../HeightHandle";
import { SpectroscopyForm } from "./SpectroscopyForm";
export type SpectroscopyFormData = {
@@ -22,8 +23,31 @@ export type SpectroscopyFormData = {
const PLOT_ASPECT_RATIO = "equal";
+/** Where this browser remembers the plots' dragged height */
+const PLOTS_HEIGHT_KEY = "visr.spectroscopy.plotsHeight";
+
+function storedPlotsHeight(): number | null {
+ try {
+ const value = Number(localStorage.getItem(PLOTS_HEIGHT_KEY));
+ return value > 0 ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+function storePlotsHeight(height: number | null) {
+ try {
+ if (height) localStorage.setItem(PLOTS_HEIGHT_KEY, String(height));
+ else localStorage.removeItem(PLOTS_HEIGHT_KEY);
+ } catch {
+ // not remembered, which is fine
+ }
+}
+
function SpectroscopyView() {
const [drawerOpen, setDrawerOpen] = useState(true);
+ const [plotsHeight, setPlotsHeight] = useState(storedPlotsHeight);
+ const plotsRef = useRef
(null);
// -------------------------------------------------------------------------
// Set off workflow when scan ends
@@ -65,6 +89,17 @@ function SpectroscopyView() {
+ {
+ setPlotsHeight(height);
+ storePlotsHeight(height);
+ }}
+ label="Height of the plots"
/>
{
+ 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();
+ });
+});
diff --git a/apps/visr/src/components/spectroscopy/plotKinds.ts b/apps/visr/src/components/spectroscopy/plotKinds.ts
new file mode 100644
index 00000000..0483e3c4
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/plotKinds.ts
@@ -0,0 +1,40 @@
+import type { Beam, PlotKind } from "./useSpectroscopyData";
+
+/** The ways a scan can be drawn, with what each shows */
+export const PLOT_KINDS: { kind: PlotKind; label: string; help: string }[] = [
+ {
+ kind: "cells",
+ label: "Setpoint grid",
+ help: "The grid of the scan's setpoints (from its spec), each cell the mean of the points in it",
+ },
+ {
+ kind: "rectangles",
+ label: "Readbacks",
+ help: "A step-sized rectangle at each position the motors actually read back",
+ },
+ {
+ kind: "gaussian",
+ label: "Gaussian",
+ help: "A beam-sized Gaussian on each point: gaps the beam didn't cover show",
+ },
+ {
+ kind: "ovals",
+ label: "Footprints",
+ help: "Each point's beam footprint, overlaps split between neighbours",
+ },
+];
+
+/** 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/progress.test.ts b/apps/visr/src/components/spectroscopy/progress.test.ts
new file mode 100644
index 00000000..c8c05506
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/progress.test.ts
@@ -0,0 +1,26 @@
+import { describeOffset, describeProgress, percentDone } from "./progress";
+
+const progress = (nPoints: number, nTotal: number | null, offset = null) => ({
+ nPoints,
+ nTotal,
+ offset,
+});
+
+describe("progress", () => {
+ it("counts the points against the scan's total", () => {
+ expect(percentDone(progress(50, 200))).toBe(25);
+ expect(describeProgress(progress(50, 200))).toBe("50 of 200 points");
+ });
+
+ it("has no percentage without a total", () => {
+ expect(percentDone(progress(1, null))).toBe(null);
+ expect(describeProgress(progress(1, null))).toBe("1 point");
+ });
+
+ it("mentions a shift onto the grid only when there is one", () => {
+ expect(describeOffset(progress(1, 1))).toBe(null);
+ expect(
+ describeOffset({ nPoints: 1, nTotal: 1, offset: { x: 0.05, y: 0 } }),
+ ).toBe("Readbacks shifted by (0.05, 0) onto the setpoint grid");
+ });
+});
diff --git a/apps/visr/src/components/spectroscopy/progress.ts b/apps/visr/src/components/spectroscopy/progress.ts
new file mode 100644
index 00000000..419a410d
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/progress.ts
@@ -0,0 +1,20 @@
+import type { Progress } from "./useSpectroscopyData";
+
+/** How far the scan has got, in percent, or null if its size isn't known */
+export function percentDone({ nPoints, nTotal }: Progress): number | null {
+ if (!nTotal) return null;
+ return Math.min(100, (100 * nPoints) / nTotal);
+}
+
+/** "120 of 289 points", or "120 points" without a known total */
+export function describeProgress({ nPoints, nTotal }: Progress): string {
+ const points = (n: number) => `${n} point${n === 1 ? "" : "s"}`;
+ return nTotal ? `${nPoints} of ${points(nTotal)}` : points(nPoints);
+}
+
+/** Says how far the readbacks were shifted onto the setpoint grid, if they were */
+export function describeOffset({ offset }: Progress): string | null {
+ if (!offset || (offset.x === 0 && offset.y === 0)) return null;
+ const n = (v: number) => Number(v.toPrecision(3));
+ return `Readbacks shifted by (${n(offset.x)}, ${n(offset.y)}) onto the setpoint grid`;
+}
diff --git a/apps/visr/src/components/spectroscopy/region.test.ts b/apps/visr/src/components/spectroscopy/region.test.ts
new file mode 100644
index 00000000..599f4ad8
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/region.test.ts
@@ -0,0 +1,38 @@
+import type { SelectionBase } from "@diamondlightsource/davidia";
+import { describeRegion, selectionToRegion } from "./region";
+
+/** A drawn rectangle from (x, y), w across and h up (either may be negative) */
+function rectangle(x: number, y: number, w: number, h: number) {
+ const corners = [
+ [x, y],
+ [x + w, y],
+ [x + w, y + h],
+ [x, y + h],
+ ];
+ return {
+ getPoints: () => corners.map(([x, y]) => ({ x, y, z: 0 })),
+ } as unknown as SelectionBase;
+}
+
+describe("selectionToRegion", () => {
+ it("covers the rectangle drawn, whichever corner it was started from", () => {
+ expect(selectionToRegion(rectangle(2, 5, -1.5, 2))).toEqual({
+ x0: 0.5,
+ x1: 2,
+ y0: 5,
+ y1: 7,
+ });
+ });
+
+ it("ignores a rectangle with no area", () => {
+ expect(selectionToRegion(rectangle(2, 5, 0, 2))).toBe(null);
+ });
+});
+
+describe("describeRegion", () => {
+ it("rounds to 3 significant figures", () => {
+ expect(describeRegion({ x0: 0.12345, x1: 2, y0: -1, y1: 10.006 })).toBe(
+ "x 0.123 to 2, y -1 to 10",
+ );
+ });
+});
diff --git a/apps/visr/src/components/spectroscopy/region.ts b/apps/visr/src/components/spectroscopy/region.ts
new file mode 100644
index 00000000..1f2da67e
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/region.ts
@@ -0,0 +1,26 @@
+import type { SelectionBase } from "@diamondlightsource/davidia";
+import type { Region } from "./useSpectroscopyData";
+
+/** The region a selection drawn on a plot covers (its bounding box), or null if it has no area */
+export function selectionToRegion(selection: SelectionBase): Region | null {
+ const points = selection.getPoints();
+ if (!points.length) return null;
+ const xs = points.map(p => p.x);
+ const ys = points.map(p => p.y);
+ const region = {
+ x0: Math.min(...xs),
+ x1: Math.max(...xs),
+ y0: Math.min(...ys),
+ y1: Math.max(...ys),
+ };
+ if (![region.x0, region.x1, region.y0, region.y1].every(Number.isFinite)) {
+ return null;
+ }
+ return region.x1 > region.x0 && region.y1 > region.y0 ? region : null;
+}
+
+/** A region as "x a to b, y c to d", to 3 significant figures */
+export function describeRegion({ x0, x1, y0, y1 }: Region): string {
+ const n = (v: number) => Number(v.toPrecision(3));
+ return `x ${n(x0)} to ${n(x1)}, y ${n(y0)} to ${n(y1)}`;
+}
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..5b99e1d5
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts
@@ -0,0 +1,243 @@
+import {
+ binEdgesToAxis,
+ CELL_PIXELS,
+ fetchData,
+ fetchRender,
+ toChannels,
+ EMPTY_STRIPE_GREYS,
+ splitEdges,
+ 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 out: number[][] = [];
+ for (let y = 0; y < CELL_PIXELS; y++) {
+ for (let x = 0; x < CELL_PIXELS; x++) {
+ const py = row * CELL_PIXELS + y;
+ const px = col * CELL_PIXELS + 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 grey stripes, unlike any measured value", () => {
+ const empty = cell([[0, null, 10]], 0, 1);
+ const greys = new Set(
+ empty.map(([r, g, b]) => (r === g && g === b ? r : -1)),
+ );
+ expect([...greys].sort()).toEqual([...EMPTY_STRIPE_GREYS].sort());
+ 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, CELL_PIXELS per cell", () => {
+ const image = toRgbNdt(
+ [
+ [1, 2, 3],
+ [4, 5, 6],
+ ],
+ "green",
+ );
+ expect(image.shape).toEqual([2 * CELL_PIXELS, 3 * CELL_PIXELS, 3]);
+ });
+});
+
+describe("splitEdges", () => {
+ it("splits each bin into CELL_PIXELS equal parts, keeping the outer edges", () => {
+ const edges = splitEdges([0, 4, 12]);
+ expect(edges).toHaveLength(2 * CELL_PIXELS + 1);
+ expect(edges[0]).toBe(0);
+ expect(edges[CELL_PIXELS]).toBe(4);
+ expect(edges[edges.length - 1]).toBe(12);
+ expect(edges[1] - edges[0]).toBeCloseTo(4 / CELL_PIXELS);
+ });
+});
+
+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("toRgbNdt with /render's layers", () => {
+ const one = { cellPixels: 1 };
+
+ it("fades a partly covered pixel into the empty stripes", () => {
+ const [r, g, b] = [0, 1, 2].map(ch =>
+ toRgbNdt([[10, 20]], "red", { ...one, coverage: [[1, 0.5]] }).get(
+ 0,
+ 1,
+ ch,
+ ),
+ );
+ const grey = EMPTY_STRIPE_GREYS[0]; // pixel (0, 1) is on the first stripe
+ expect([r, g, b]).toEqual([
+ Math.round(0.5 * 255 + 0.5 * grey),
+ Math.round(0.5 * grey),
+ Math.round(0.5 * grey),
+ ]);
+ });
+
+ it("draws outlines in white over the colour", () => {
+ const image = toRgbNdt([[10, 20]], "red", { ...one, outline: [[0, 1]] });
+ expect([0, 1, 2].map(ch => image.get(0, 1, ch))).toEqual([255, 255, 255]);
+ expect([0, 1, 2].map(ch => image.get(0, 0, ch))).toEqual([0, 0, 0]);
+ });
+});
+
+describe("toChannels", () => {
+ it("gives /render's pixels axes spanning its viewport", () => {
+ const rows = [
+ [1, 2],
+ [3, 4],
+ ];
+ const channels = toChannels({
+ viewport: { x0: -1, x1: 1, y0: 0, y1: 4, width: 2, height: 2 },
+ channels: { RedTotal: rows, GreenTotal: rows, BlueTotal: rows },
+ coverage: [
+ [1, 1],
+ [1, 1],
+ ],
+ });
+ expect(Array.from(channels.xValues.data as Float64Array)).toEqual([
+ -1, 0, 1,
+ ]);
+ expect(Array.from(channels.yValues.data as Float64Array)).toEqual([
+ 0, 2, 4,
+ ]);
+ expect(channels.red.shape).toEqual([2, 2, 3]);
+ });
+});
+
+describe("fetchRender", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("asks for just the region, when there is one", async () => {
+ const fetch = vi.fn<(url: string) => Promise>(
+ async () => new Response("{}"),
+ );
+ vi.stubGlobal("fetch", fetch);
+ await fetchRender("uid", "cells", { x0: 1, x1: 2, y0: -3, y1: 4.5 });
+ const url = new URL(fetch.mock.calls[0][0], "http://host");
+ expect(url.pathname).toBe("/api/data/render/uid");
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ kind: "cells",
+ size: "200",
+ x0: "1",
+ x1: "2",
+ y0: "-3",
+ y1: "4.5",
+ });
+ });
+});
+
+describe("toChannels progress", () => {
+ it("passes on how far the scan has got", () => {
+ const channels = toChannels({
+ RedTotal: [[1]],
+ GreenTotal: [[1]],
+ BlueTotal: [[1]],
+ x_limits: [0, 1],
+ y_limits: [0, 1],
+ n_points: 3,
+ n_total: 9,
+ position_offset: { x: 0.1, y: 0 },
+ });
+ expect(channels.progress).toEqual({
+ nPoints: 3,
+ nTotal: 9,
+ offset: { x: 0.1, y: 0 },
+ });
+ });
+
+ it("has none when the server doesn't say", () => {
+ const rows = [[1]];
+ expect(
+ toChannels({
+ RedTotal: rows,
+ GreenTotal: rows,
+ BlueTotal: rows,
+ x_limits: [0, 1],
+ y_limits: [0, 1],
+ }).progress,
+ ).toBeUndefined();
+ });
+});
diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts
index 6efefc25..67a50cd8 100644
--- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts
+++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts
@@ -1,4 +1,4 @@
-import { type NDT } from "@diamondlightsource/davidia";
+import type { NDT } from "@diamondlightsource/davidia";
import ndarray from "ndarray";
import { useEffect, useRef, useState } from "react";
import { useScanEvents } from "../../hooks/scanEvents/useScanEvents";
@@ -10,97 +10,292 @@ export interface DataChannels {
blue: NDT;
xValues: NDT;
yValues: NDT;
+ /** The beam size /render drew with, and where it came from (Gaussian and footprints) */
+ beam?: Beam;
+ /** How far the scan has got, when the server says */
+ progress?: Progress;
}
+/** How far a scan has got, from /binned's or /render's answer */
+export type Progress = {
+ /** Points recorded so far */
+ nPoints: number;
+ /** Points the scan will have, or null if the run has no spec */
+ nTotal: number | null;
+ /** How far the readbacks were shifted onto the setpoint grid, or null */
+ offset: { x: number; y: number } | null;
+};
+
+/** A region of the scan to draw, in motor units */
+export type Region = { x0: number; x1: number; y0: number; y1: number };
+
+/** The fields both endpoints use for progress */
+type ProgressFields = {
+ n_points?: number;
+ n_total?: number | null;
+ position_offset?: { x: number; y: number } | null;
+};
+
+/** 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)[][];
/** 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[];
-};
+} & ProgressFields;
+
+export type RGBColour = "red" | "green" | "blue" | "gray";
+
+/**
+ * Each cell is drawn as CELL_PIXELS x CELL_PIXELS pixels, so cells no point has
+ * landed in yet can carry a pattern: diagonal stripes in two greys, which can't
+ * be mistaken for a measured value (dark or not) and run on unbroken across
+ * the empty part of the scan. The axes are split to match.
+ */
+export const CELL_PIXELS = 4;
+export const EMPTY_STRIPE_GREYS = [142, 120] as const;
+
+function emptyGrey(row: number, col: number): number {
+ return EMPTY_STRIPE_GREYS[Math.floor(((row + col) % 4) / 2)];
+}
+
+/** Bin edges with each bin split into CELL_PIXELS equal parts, to match the image */
+export function splitEdges(edges: number[]): number[] {
+ const out: number[] = [];
+ for (let i = 0; i < edges.length - 1; i++) {
+ for (let k = 0; k < CELL_PIXELS; k++) {
+ out.push(edges[i] + ((edges[i + 1] - edges[i]) * k) / CELL_PIXELS);
+ }
+ }
+ out.push(edges[edges.length - 1]);
+ return out;
+}
-type RGBColour = "red" | "green" | "blue" | "gray";
+/** How to draw a matrix as an image */
+export type RgbOptions = {
+ /** Pixels per matrix cell on each axis (CELL_PIXELS for /binned's cells, 1 for /render's pixels) */
+ cellPixels?: number;
+ /** How much of each cell is drawn, 0 to 1 (/render): the rest shows the empty stripes */
+ coverage?: number[][];
+ /** Opacity of white outlines over each cell, 0 to 1 (/render's footprints) */
+ outline?: number[][];
+};
-function toRgbNdt(matrix: (number | null)[][], colour: RGBColour): NDT {
+/**
+ * The matrix as an RGB image in one colour, scaled over its own range, each
+ * cell `cellPixels` pixels square. Cells without a value are grey stripes,
+ * 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)[][],
+ colour: RGBColour,
+ { cellPixels = CELL_PIXELS, coverage, outline }: RgbOptions = {},
+): NDT {
if (!matrix?.length || !matrix[0]?.length) {
return EMPTY_NDT; // skip invalid input
}
const height = matrix.length;
const width = matrix[0].length;
-
- // Flatten and filter out nulls for normalisation
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;
+ 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 channel = { 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 grey = emptyGrey(y, x);
+ const covered = v === null || isNaN(v) ? 0 : (coverage?.[r]?.[c] ?? 1);
+ const scaled = max > min ? (v! - min) * scale : 255;
+ for (let k = 0; k < 3; k++) {
+ const value = channel.includes(k) ? scaled : 0;
+ let out = covered * value + (1 - covered) * grey;
+ const line = outline?.[r]?.[c] ?? 0;
+ out = out * (1 - line) + 255 * line;
+ rgb[i + k] = Math.round(out);
+ }
}
}
- return ndarray(rgb, [height, width, 3]) as NDT;
+ return ndarray(rgb, [H, W, 3]) as NDT;
}
+
/** Placeholder empty gray dataset */
-const EMPTY_NDT = toRgbNdt([[0, 0, 0]], "gray");
+const EMPTY_NDT = toRgbNdt([[null, null, null]], "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;
- }
+/** How the scan is drawn: /binned's cells, or one of /render's kinds */
+export type PlotKind = "cells" | "rectangles" | "gaussian" | "ovals";
- return ndarray(centres, [centres.length]);
-}
+/** Longest side, in pixels, of the images /render draws */
+export const RENDER_SIZE = 200;
+
+/** 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",
+ (number | null)[][]
+ >;
+ coverage: number[][];
+ outline?: number[][];
+ beam?: Beam;
+} & ProgressFields;
-async function fetchData(uuid: string): Promise {
- const url = `/api/data/binned/${uuid}`;
+/** The JSON at `url`, or NotReady on a 503 (waiting as Retry-After asks, 1 s by default) */
+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
}
+/**
+ * The binned images of a run, 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.
+ */
+export function fetchData(uuid: string): Promise {
+ return fetchJson(`/api/data/binned/${uuid}`);
+}
+
+/** A run drawn by /render over `region` (default: the scan's extent), or NotReady */
+export function fetchRender(
+ uuid: string,
+ kind: PlotKind,
+ region?: Region,
+): Promise {
+ const params = new URLSearchParams({ kind, size: String(RENDER_SIZE) });
+ if (region) {
+ for (const [key, value] of Object.entries(region)) {
+ params.set(key, String(value));
+ }
+ }
+ return fetchJson(`/api/data/render/${uuid}?${params}`);
+}
+
+/** 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,
+ );
+}
+
+/** The scan's progress from /binned's or /render's answer, if it has one */
+export function toProgress(resp: ProgressFields): Progress | undefined {
+ if (typeof resp.n_points !== "number") return undefined;
+ return {
+ nPoints: resp.n_points,
+ nTotal: resp.n_total ?? null,
+ offset: resp.position_offset ?? null,
+ };
+}
+
+/** The channels and axes to plot from /binned's or /render's answer */
+export function toChannels(resp: SpectroscopyData | RenderData): DataChannels {
+ const progress = toProgress(resp);
+ if ("viewport" in resp) {
+ const v = resp.viewport;
+ const options = {
+ cellPixels: 1,
+ coverage: resp.coverage,
+ outline: resp.outline,
+ };
+ return {
+ red: toRgbNdt(resp.channels.RedTotal, "red", options),
+ green: toRgbNdt(resp.channels.GreenTotal, "green", options),
+ blue: toRgbNdt(resp.channels.BlueTotal, "blue", options),
+ xValues: binEdgesToAxis(pixelEdges(v.x0, v.x1, v.width)),
+ yValues: binEdgesToAxis(pixelEdges(v.y0, v.y1, v.height)),
+ beam: resp.beam,
+ progress,
+ };
+ }
+ return {
+ red: toRgbNdt(resp.RedTotal, "red"),
+ green: toRgbNdt(resp.GreenTotal, "green"),
+ blue: toRgbNdt(resp.BlueTotal, "blue"),
+ xValues: binEdgesToAxis(splitEdges(resp.x_limits)),
+ yValues: binEdgesToAxis(splitEdges(resp.y_limits)),
+ progress,
+ };
+}
+
+/** Tries for the final image of a scan, when a 503 isn't followed by another poll */
+const FINAL_POLL_ATTEMPTS = 10;
+
// initial axes must have min three points...
-const initialAxes = binEdgesToCentrePoints([-0.25, 0.25, 0.5, 0.75]);
+const initialAxes = binEdgesToAxis(splitEdges([-0.25, 0.25, 0.5, 0.75]));
-export function useSpectroscopyData(): {
+/**
+ * The latest images of the current scan, drawn as `kind`, polled while it
+ * runs and once more when it ends. With a `region`, only that part of the
+ * scan is drawn (by /render, at its full resolution).
+ */
+export function useSpectroscopyData(
+ kind: PlotKind = "cells",
+ region?: Region,
+): {
data: DataChannels;
running: boolean;
+ /** The scan being shown */
+ uuid: string | null;
} {
const scanEvent = useScanEvents();
const [running, setRunning] = useState(false);
@@ -133,40 +328,54 @@ export function useSpectroscopyData(): {
// Poll during scan + once more afterwards
useEffect(() => {
- async function poll() {
- if (!uuid) return;
+ let cancelled = false;
+
+ /** One poll: the wait the server asks for if it isn't ready, else 0 */
+ async function poll(): Promise {
+ if (!uuid) return 0;
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" && !region
+ ? fetchData(uuid)
+ : fetchRender(uuid, kind, region));
+ if ("notReady" in resp) return resp.retryAfterMs; // the next poll tries again
+ setData(toChannels(resp));
} catch (err) {
console.error("Polling error:", err);
}
+ return 0;
+ }
+
+ /** 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 wait = await poll();
+ if (!wait) return;
+ await new Promise(resolve => setTimeout(resolve, wait));
+ }
}
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, region]);
- return { data, running };
+ return { data, running, uuid };
}
diff --git a/apps/visr/src/components/tomography/ReconstructionSelect.tsx b/apps/visr/src/components/tomography/ReconstructionSelect.tsx
new file mode 100644
index 00000000..131de194
--- /dev/null
+++ b/apps/visr/src/components/tomography/ReconstructionSelect.tsx
@@ -0,0 +1,40 @@
+import { Box, MenuItem, TextField, Typography } from "@mui/material";
+import type { Reconstruction } from "./reconstruction";
+
+interface Props {
+ reconstructions: Reconstruction[];
+ selected: string | null;
+ onSelect: (path: string) => void;
+}
+
+/** Picks which reconstruction in Tiled the viewer shows */
+export default function ReconstructionSelect({
+ reconstructions,
+ selected,
+ onSelect,
+}: Props) {
+ return (
+
+
+ Reconstruction
+
+ onSelect(e.target.value)}
+ disabled={reconstructions.length === 0}
+ helperText={
+ reconstructions.length === 0 ? "No reconstructions in Tiled" : " "
+ }
+ >
+ {reconstructions.map(r => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/visr/src/components/tomography/TomographyView.tsx b/apps/visr/src/components/tomography/TomographyView.tsx
index 41680b13..ed2cefee 100644
--- a/apps/visr/src/components/tomography/TomographyView.tsx
+++ b/apps/visr/src/components/tomography/TomographyView.tsx
@@ -1,10 +1,16 @@
-import { Box } from "@mui/material";
+import { Box, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import TomographyControls from "./TomographyControls";
import { TomographyForm } from "./TomographyForm";
import { Plane } from "./PlaneEnum";
import ControlsDrawer from "../ControlsDrawer";
import TomographyPlots from "./TomographyPlots";
+import ReconstructionSelect from "./ReconstructionSelect";
+import {
+ fetchVolume,
+ listReconstructions,
+ type Reconstruction,
+} from "./reconstruction";
// ---------------------------------------------------------------------------
// Types
@@ -27,28 +33,41 @@ function TomographyView() {
const [plane, setPlane] = useState(Plane.Z);
const [drawerOpen, setDrawerOpen] = useState(true);
+ const [reconstructions, setReconstructions] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [status, setStatus] = useState("Looking for reconstructions…");
+
// -------------------------------------------------------------------------
- // Load test volume
+ // Find the reconstructions in Tiled, and show the newest
// -------------------------------------------------------------------------
useEffect(() => {
- async function loadTestVolume() {
- const [metaRes, rawRes] = await Promise.all([
- fetch("/test-data/volume.json"),
- fetch("/test-data/volume.raw"),
- ]);
- const meta = (await metaRes.json()) as {
- shape: [number, number, number];
- };
- const buffer = await rawRes.arrayBuffer();
- setVolume({
- volumeData: new Uint8Array(buffer),
- volumeShape: meta.shape,
- });
- }
-
- loadTestVolume();
+ listReconstructions()
+ .then(found => {
+ setReconstructions(found);
+ if (found.length === 0) setStatus("No reconstructions in Tiled yet");
+ else setSelected(found[0].path);
+ })
+ .catch((e: Error) => setStatus(e.message));
}, []);
+ useEffect(() => {
+ const reconstruction = reconstructions.find(r => r.path === selected);
+ if (!reconstruction) return;
+ let current = true;
+ setStatus(`Loading ${reconstruction.label}…`);
+ fetchVolume(reconstruction)
+ .then(volumeData => {
+ if (!current) return;
+ setVolume({ volumeData, volumeShape: reconstruction.shape });
+ // the old slice may be past the end of this volume
+ setSlice(s => Math.min(s, Math.min(...reconstruction.shape) - 1));
+ })
+ .catch((e: Error) => current && setStatus(e.message));
+ return () => {
+ current = false;
+ };
+ }, [reconstructions, selected]);
+
// -------------------------------------------------------------------------
// Persist values across multiple open tabs
// -------------------------------------------------------------------------
@@ -97,7 +116,13 @@ function TomographyView() {
// revolve to be implemented
- if (!volume) return ;
+ if (!volume) {
+ return (
+
+ {status}
+
+ );
+ }
// -------------------------------------------------------------------------
// Render
@@ -124,6 +149,11 @@ function TomographyView() {
onToggle={() => setDrawerOpen(prev => !prev)}
controls={[
,
+ ,
= {},
+ shape = [2, 3, 4],
+ dataType = { kind: "u", itemsize: 1 },
+) {
+ return {
+ id,
+ attributes: {
+ structure_family: "array",
+ metadata: { visr: { kind: "tomography-reconstruction", ...visr } },
+ structure: { shape, data_type: dataType },
+ },
+ };
+}
+
+describe("toReconstruction", () => {
+ it("names it by key and workflow run", () => {
+ expect(
+ toReconstruction(
+ result("recon", { workflow_run: "run-1", created: "2026-10-15" }),
+ ),
+ ).toEqual({
+ path: "analysis/recon",
+ label: "recon (run-1)",
+ shape: [2, 3, 4],
+ created: "2026-10-15",
+ });
+ });
+
+ it("skips what the viewer can't show", () => {
+ expect(toReconstruction(result("flat", {}, [2, 3]))).toBeNull();
+ expect(
+ toReconstruction(
+ result("wide", {}, [2, 3, 4], { kind: "u", itemsize: 2 }),
+ ),
+ ).toBeNull();
+ expect(
+ toReconstruction(
+ result("float", {}, [2, 3, 4], { kind: "f", itemsize: 1 }),
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("listReconstructions", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("searches the analysis container by kind, newest first", async () => {
+ const fetch = vi.fn<(url: string) => Promise>(
+ async () =>
+ new Response(
+ JSON.stringify({
+ data: [
+ result("old", { created: "2026-10-01T00:00:00Z" }),
+ result("flat", {}, [5]),
+ result("new", { created: "2026-10-15T00:00:00Z" }),
+ ],
+ }),
+ ),
+ );
+ vi.stubGlobal("fetch", fetch);
+ const found = await listReconstructions();
+ expect(found.map(r => r.path)).toEqual(["analysis/new", "analysis/old"]);
+ const url = new URL(fetch.mock.calls[0][0], "http://host");
+ expect(url.pathname).toBe("/api/data/search/analysis");
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ "filter[eq][condition][key]": "visr.kind",
+ "filter[eq][condition][value]": '"tomography-reconstruction"',
+ });
+ });
+
+ it("finds none when there is no analysis container", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response("", { status: 404 })),
+ );
+ expect(await listReconstructions()).toEqual([]);
+ });
+});
+
+describe("fetchVolume", () => {
+ afterEach(() => vi.unstubAllGlobals());
+ const recon: Reconstruction = {
+ path: "analysis/recon",
+ label: "recon",
+ shape: [2, 3, 4],
+ };
+
+ it("reads the array's bytes", async () => {
+ const fetch = vi.fn<(url: string) => Promise>(
+ async () => new Response(new Uint8Array(24).fill(7)),
+ );
+ vi.stubGlobal("fetch", fetch);
+ const data = await fetchVolume(recon);
+ expect(data).toHaveLength(24);
+ expect(data[23]).toBe(7);
+ expect(fetch.mock.calls[0][0]).toBe(
+ "/api/data/array/full/analysis/recon?format=application/octet-stream",
+ );
+ });
+
+ it("refuses bytes that don't fit the shape", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(new Uint8Array(10))),
+ );
+ await expect(fetchVolume(recon)).rejects.toThrow("10 bytes");
+ });
+});
diff --git a/apps/visr/src/components/tomography/reconstruction.ts b/apps/visr/src/components/tomography/reconstruction.ts
new file mode 100644
index 00000000..9df7b11d
--- /dev/null
+++ b/apps/visr/src/components/tomography/reconstruction.ts
@@ -0,0 +1,89 @@
+/** Reconstructed volumes, read from Tiled through the /api/data proxy */
+
+/** The Tiled container that holds results derived from raw data */
+export const ANALYSIS = "analysis";
+
+/** The metadata `visr.kind` that marks a node as a tomography reconstruction */
+export const RECONSTRUCTION_KIND = "tomography-reconstruction";
+
+export interface Reconstruction {
+ /** Path of the array node in Tiled, e.g. "analysis/visr-tomo-test-recon" */
+ path: string;
+ /** What to call it in the UI */
+ label: string;
+ shape: [number, number, number];
+ /** When it was registered (ISO 8601), if the metadata says */
+ created?: string;
+}
+
+/** The parts of a Tiled search result this reads */
+interface SearchResult {
+ id: string;
+ attributes: {
+ structure_family: string;
+ metadata: {
+ visr?: { workflow_run?: string; created?: string; kind?: string };
+ };
+ structure?: {
+ shape: number[];
+ data_type?: { kind: string; itemsize: number };
+ };
+ };
+}
+
+/** A search result as a Reconstruction, or null if the UI can't show it */
+export function toReconstruction(result: SearchResult): Reconstruction | null {
+ const { structure_family, structure, metadata } = result.attributes;
+ const type = structure?.data_type;
+ // the viewer takes a 3D volume of bytes, as the workflow writes
+ if (
+ structure_family !== "array" ||
+ structure?.shape.length !== 3 ||
+ type?.kind !== "u" ||
+ type.itemsize !== 1
+ ) {
+ return null;
+ }
+ const run = metadata.visr?.workflow_run;
+ return {
+ path: `${ANALYSIS}/${result.id}`,
+ label: run ? `${result.id} (${run})` : result.id,
+ shape: structure.shape as [number, number, number],
+ created: metadata.visr?.created,
+ };
+}
+
+/** The reconstructions in Tiled that the viewer can show, newest first */
+export async function listReconstructions(): Promise {
+ const params = new URLSearchParams({
+ "filter[eq][condition][key]": "visr.kind",
+ "filter[eq][condition][value]": JSON.stringify(RECONSTRUCTION_KIND),
+ });
+ const resp = await fetch(`/api/data/search/${ANALYSIS}?${params}`);
+ // no analysis container yet: nothing has been reconstructed
+ if (resp.status === 404) return [];
+ if (!resp.ok) throw new Error(`Listing reconstructions: ${resp.statusText}`);
+ const body = (await resp.json()) as { data: SearchResult[] };
+ return body.data
+ .map(toReconstruction)
+ .filter((r): r is Reconstruction => r !== null)
+ .sort((a, b) => (b.created ?? "").localeCompare(a.created ?? ""));
+}
+
+/** The whole volume of a reconstruction, as bytes in C order */
+export async function fetchVolume(
+ reconstruction: Reconstruction,
+): Promise {
+ const resp = await fetch(
+ `/api/data/array/full/${reconstruction.path}?format=application/octet-stream`,
+ );
+ if (!resp.ok) throw new Error(`Loading the volume: ${resp.statusText}`);
+ const data = new Uint8Array(await resp.arrayBuffer());
+ const expected = reconstruction.shape.reduce((a, b) => a * b, 1);
+ if (data.length !== expected) {
+ throw new Error(
+ `Volume has ${data.length} bytes; its shape needs ${expected}`,
+ );
+ }
+ return data;
+}
diff --git a/apps/visr/src/mocks/handlers.ts b/apps/visr/src/mocks/handlers.ts
index 5ef848fa..9cd6f3dc 100644
--- a/apps/visr/src/mocks/handlers.ts
+++ b/apps/visr/src/mocks/handlers.ts
@@ -1,4 +1,4 @@
-import { http, HttpResponse, graphql } from "msw";
+import { http, HttpResponse, graphql, bypass } from "msw";
import workflowsResponse from "./workflows-response.json";
import plansResponse from "./plans-response.json";
import { binnedReadback, binnedSetpoint, mapData } from "./mock_data";
@@ -127,4 +127,35 @@ export const handlers = [
},
});
}),
+ // Tomography: Tiled's analysis container holds one reconstruction, the test volume
+ http.get("/api/data/search/analysis", () =>
+ HttpResponse.json({
+ data: [
+ {
+ id: "test-volume",
+ attributes: {
+ structure_family: "array",
+ metadata: {
+ visr: {
+ kind: "tomography-reconstruction",
+ created: "2026-10-15T00:00:00+00:00",
+ },
+ },
+ // the shape in public/test-data/volume.json
+ structure: {
+ shape: [128, 91, 91],
+ data_type: { kind: "u", itemsize: 1 },
+ },
+ },
+ },
+ ],
+ }),
+ ),
+
+ http.get("/api/data/array/full/analysis/test-volume", async () => {
+ const raw = await fetch(bypass("/test-data/volume.raw"));
+ return new HttpResponse(await raw.arrayBuffer(), {
+ headers: { "Content-Type": "application/octet-stream" },
+ });
+ }),
];