From 37a70d2625b8d5339c79e7f5e5f81cadd6a13d4f Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Wed, 14 Oct 2026 13:56:20 +0100 Subject: [PATCH 1/9] ViSR: show unmeasured cells as grey stripes, and a single point at all Cells no point had landed in yet were drawn black, the same as a measured low value, so a scan in progress looked like dark data. They are now grey diagonal stripes: each cell is drawn as 4x4 pixels so the empty part can carry the pattern, and the axes are split to match. A flat image (a single point, or all values equal) was scaled to black, since its range is zero; it is now drawn at full brightness. Also imports NDT as a type only, so the module no longer loads davidia at runtime and can be unit-tested. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fy4Rv41HcbMRMkxzmCCJga --- .../spectroscopy/useSpectroscopyData.test.ts | 79 ++++++++++++ .../spectroscopy/useSpectroscopyData.ts | 113 ++++++++++++------ 2 files changed, 155 insertions(+), 37 deletions(-) create mode 100644 apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts 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..e964f7ae --- /dev/null +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts @@ -0,0 +1,79 @@ +import { + CELL_PIXELS, + 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); + }); +}); diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 6efefc25..429f6df1 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"; @@ -14,18 +14,49 @@ export interface DataChannels { /** 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[]; }; -type RGBColour = "red" | "green" | "blue" | "gray"; +export type RGBColour = "red" | "green" | "blue" | "gray"; -function toRgbNdt(matrix: (number | null)[][], colour: RGBColour): NDT { +/** + * 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; +} + +/** + * The matrix as an RGB image in one colour, scaled over its own range, each + * cell CELL_PIXELS pixels square. Cells without a value are grey stripes. 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): NDT { if (!matrix?.length || !matrix[0]?.length) { return EMPTY_NDT; // skip invalid input } @@ -39,39 +70,45 @@ function toRgbNdt(matrix: (number | null)[][], colour: RGBColour): NDT { // 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; - - const rgb = new Uint8Array(width * height * 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; + const scale = max > min ? 255 / (max - min) : 0; + + const W = width * CELL_PIXELS; + const H = height * CELL_PIXELS; + const rgb = new Uint8Array(W * H * 3); + + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const i = y * W + x; + const v = + flat[Math.floor(y / CELL_PIXELS) * width + Math.floor(x / CELL_PIXELS)]; + if (v === null || isNaN(v)) { + rgb[i * 3] = rgb[i * 3 + 1] = rgb[i * 3 + 2] = emptyGrey(y, x); + continue; + } + const scaled = max > min ? Math.round((v - min) * scale) : 255; + 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; + } } } - 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 { @@ -96,7 +133,9 @@ async function fetchData(uuid: string): Promise { } // initial axes must have min three points... -const initialAxes = binEdgesToCentrePoints([-0.25, 0.25, 0.5, 0.75]); +const initialAxes = binEdgesToCentrePoints( + splitEdges([-0.25, 0.25, 0.5, 0.75]), +); export function useSpectroscopyData(): { data: DataChannels; @@ -141,8 +180,8 @@ export function useSpectroscopyData(): { 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), + xValues: binEdgesToCentrePoints(splitEdges(resp.x_limits)), + yValues: binEdgesToCentrePoints(splitEdges(resp.y_limits)), }); } catch (err) { console.error("Polling error:", err); From f8b669881d694e8ac35d3a13f6faaa0865232f2d Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Wed, 14 Oct 2026 14:07:10 +0100 Subject: [PATCH 2/9] ViSR: place each cell between its bin edges, not half a cell off The UI turned /binned's bin edges into cell centres for the plot, which reads values of the same length as the image as each pixel's leading edge, so the whole image sat half a cell right and down of where the scan measured it (a 2x2 scan with edges -0.5, 0.5, 1.5 was drawn over 0..2). The plot is now given the edges themselves, one more than the cells, which it places each cell between. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/useSpectroscopyData.test.ts | 13 ++++++++++ .../spectroscopy/useSpectroscopyData.ts | 26 ++++++++----------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts index e964f7ae..806c5661 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts @@ -1,4 +1,5 @@ import { + binEdgesToAxis, CELL_PIXELS, EMPTY_STRIPE_GREYS, splitEdges, @@ -77,3 +78,15 @@ describe("splitEdges", () => { 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"); + }); +}); diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 429f6df1..63ba5ff6 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -110,19 +110,17 @@ export function toRgbNdt(matrix: (number | null)[][], colour: RGBColour): NDT { /** Placeholder empty gray dataset */ 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"); } - - const centres = new Float64Array(edges.length - 1); - - for (let i = 0; i < centres.length; i++) { - centres[i] = (edges[i] + edges[i + 1]) / 2; - } - - return ndarray(centres, [centres.length]); + return ndarray(Float64Array.from(edges), [edges.length]) as NDT; } async function fetchData(uuid: string): Promise { @@ -133,9 +131,7 @@ async function fetchData(uuid: string): Promise { } // initial axes must have min three points... -const initialAxes = binEdgesToCentrePoints( - splitEdges([-0.25, 0.25, 0.5, 0.75]), -); +const initialAxes = binEdgesToAxis(splitEdges([-0.25, 0.25, 0.5, 0.75])); export function useSpectroscopyData(): { data: DataChannels; @@ -180,8 +176,8 @@ export function useSpectroscopyData(): { red: toRgbNdt(resp.RedTotal, "red"), green: toRgbNdt(resp.GreenTotal, "green"), blue: toRgbNdt(resp.BlueTotal, "blue"), - xValues: binEdgesToCentrePoints(splitEdges(resp.x_limits)), - yValues: binEdgesToCentrePoints(splitEdges(resp.y_limits)), + xValues: binEdgesToAxis(splitEdges(resp.x_limits)), + yValues: binEdgesToAxis(splitEdges(resp.y_limits)), }); } catch (err) { console.error("Polling error:", err); From ad0fea7491f4843999fc9f7a6af63fa980dc69ae Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Wed, 14 Oct 2026 14:22:28 +0100 Subject: [PATCH 3/9] ViSR: make the poll after a scan ends happen, and retry it on a 503 The poll that should fetch a scan's last points once it finished never ran: it was conditional on the poll interval still being set, but the previous run of the effect had already cleared it. So the final image was whatever the last regular poll got, up to half a second before the scan ended. It now runs whenever a scan stops running. /binned answers 503 with Retry-After while the data file is behind the catalog. That was logged as a polling error; it is now treated as "not ready yet": during a scan the next poll simply tries again, and the final poll, which nothing follows, retries after the time the server asks for (up to 10 times). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/useSpectroscopyData.test.ts | 44 ++++++++++++++ .../spectroscopy/useSpectroscopyData.ts | 59 +++++++++++++++---- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts index 806c5661..efd71a69 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts @@ -1,6 +1,7 @@ import { binEdgesToAxis, CELL_PIXELS, + fetchData, EMPTY_STRIPE_GREYS, splitEdges, toRgbNdt, @@ -90,3 +91,46 @@ describe("binEdgesToAxis", () => { 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(); + }); +}); diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 63ba5ff6..93b77c1c 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -123,13 +123,34 @@ export function binEdgesToAxis(edges: number[]): NDT { return ndarray(Float64Array.from(edges), [edges.length]) as NDT; } -async function fetchData(uuid: string): Promise { +/** /binned answered 503: the data file is behind the catalog, try again shortly */ +export type NotReady = { notReady: true; retryAfterMs: number }; + +/** + * 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 async function fetchData( + uuid: string, +): Promise { const url = `/api/data/binned/${uuid}`; 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 } +/** 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 = binEdgesToAxis(splitEdges([-0.25, 0.25, 0.5, 0.75])); @@ -168,10 +189,14 @@ 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); + const resp = await fetchData(uuid); + if ("notReady" in resp) return resp.retryAfterMs; // the next poll tries again setData({ red: toRgbNdt(resp.RedTotal, "red"), green: toRgbNdt(resp.GreenTotal, "green"), @@ -182,20 +207,34 @@ export function useSpectroscopyData(): { } 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; From a303d7b2a38fec56aa3ef06087c1e8a31f460605 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Wed, 14 Oct 2026 14:37:13 +0100 Subject: [PATCH 4/9] ViSR: choose how the scan is drawn: cells, rectangles, Gaussian or footprints A toggle above the plots picks the drawing. Cells is /binned's grid as before; the others come from Tiled's /render, drawn over the scan's extent at 200 px on its longer side: a step-sized rectangle on each recorded position, a beam-sized Gaussian on each point, or each point's beam footprint with overlaps split between neighbours. Each channel is coloured as before; where /render drew nothing or covered a pixel only partly, it fades into the empty stripes, so gaps the beam left stay visible, and the footprints' outlines are drawn in white on top. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/SpectroscopyPlots.tsx | 67 ++++++- .../spectroscopy/useSpectroscopyData.test.ts | 51 +++++ .../spectroscopy/useSpectroscopyData.ts | 184 ++++++++++++------ 3 files changed, 238 insertions(+), 64 deletions(-) diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index e3b3fc54..59a8d867 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -1,7 +1,7 @@ import { ImagePlot } from "@diamondlightsource/davidia"; -import { useSpectroscopyData } from "./useSpectroscopyData"; -import { useMemo, type ComponentProps } from "react"; -import { Box } from "@mui/material"; +import { useSpectroscopyData, type PlotKind } from "./useSpectroscopyData"; +import { useMemo, useState, type ComponentProps } from "react"; +import { Box, ToggleButton, ToggleButtonGroup, Tooltip } from "@mui/material"; const CHANNELS = [ { key: "red", label: "Red channel" }, @@ -9,6 +9,30 @@ const CHANNELS = [ { key: "blue", label: "Blue channel" }, ] as const; +/** The ways a scan can be drawn, with what each shows */ +const PLOT_KINDS: { kind: PlotKind; label: string; help: string }[] = [ + { + kind: "cells", + label: "Cells", + help: "The scan's grid cells, each the mean of the points in it", + }, + { + kind: "rectangles", + label: "Rectangles", + help: "A step-sized rectangle on each recorded position", + }, + { + 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", + }, +]; + interface SpectroscopyPlotsProps { expanded: boolean; plotAspectRatio: ComponentProps["aspect"]; @@ -18,7 +42,8 @@ function SpectroscopyPlots({ expanded, plotAspectRatio, }: SpectroscopyPlotsProps) { - const { data: channels } = useSpectroscopyData(); + const [kind, setKind] = useState("cells"); + const { data: channels } = useSpectroscopyData(kind); const plots = useMemo( () => @@ -56,14 +81,38 @@ function SpectroscopyPlots({ minWidth: 0, // below this the TabbedPanel scroller takes over minHeight: expanded ? 500 : 250, - display: "grid", - // open: 3 across. Closed: 2 on top, the third wraps underneath. - gridTemplateColumns: `repeat(${expanded ? 2 : 3}, minmax(0, 1fr))`, - gridAutoRows: "minmax(0, 1fr)", // rows share our height equally + display: "flex", + flexDirection: "column", gap: 1, }} > - {plots} + value && setKind(value)} + aria-label="How the scan is drawn" + sx={{ alignSelf: "center" }} + > + {PLOT_KINDS.map(({ kind, label, help }) => ( + + {label} + + ))} + + + {plots} + ); } diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts index efd71a69..308aa28b 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts @@ -2,6 +2,7 @@ import { binEdgesToAxis, CELL_PIXELS, fetchData, + toChannels, EMPTY_STRIPE_GREYS, splitEdges, toRgbNdt, @@ -134,3 +135,53 @@ describe("fetchData", () => { 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]); + }); +}); diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 93b77c1c..2342dc51 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -50,63 +50,71 @@ export function splitEdges(edges: number[]): number[] { return out; } +/** 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[][]; +}; + /** * The matrix as an RGB image in one colour, scaled over its own range, each - * cell CELL_PIXELS pixels square. Cells without a value are grey stripes. If - * every value is the same (a single point, say), they are drawn at full - * brightness rather than black. + * 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): NDT { +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; + 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 W = width * CELL_PIXELS; - const H = height * CELL_PIXELS; + const W = width * cellPixels; + const H = height * cellPixels; const rgb = new Uint8Array(W * H * 3); for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { - const i = y * W + x; - const v = - flat[Math.floor(y / CELL_PIXELS) * width + Math.floor(x / CELL_PIXELS)]; - if (v === null || isNaN(v)) { - rgb[i * 3] = rgb[i * 3 + 1] = rgb[i * 3 + 2] = emptyGrey(y, x); - continue; - } - const scaled = max > min ? Math.round((v - min) * scale) : 255; - 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; + 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, [H, W, 3]) as NDT; } + /** Placeholder empty gray dataset */ const EMPTY_NDT = toRgbNdt([[null, null, null]], "gray"); @@ -123,18 +131,35 @@ export function binEdgesToAxis(edges: number[]): NDT { return ndarray(Float64Array.from(edges), [edges.length]) as NDT; } -/** /binned answered 503: the data file is behind the catalog, try again shortly */ +/** /binned or /render answered 503: the data file is behind the catalog, try again shortly */ export type NotReady = { notReady: true; retryAfterMs: number }; -/** - * 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 async function fetchData( - uuid: string, -): Promise { - const url = `/api/data/binned/${uuid}`; +/** How the scan is drawn: /binned's cells, or one of /render's kinds */ +export type PlotKind = "cells" | "rectangles" | "gaussian" | "ovals"; + +/** 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[][]; +}; + +/** 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")); @@ -148,13 +173,66 @@ export async function fetchData( 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 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, + ); +} + +/** The channels and axes to plot from /binned's or /render's answer */ +export function toChannels(resp: SpectroscopyData | RenderData): DataChannels { + 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)), + }; + } + 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)), + }; +} + /** 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 = binEdgesToAxis(splitEdges([-0.25, 0.25, 0.5, 0.75])); -export function useSpectroscopyData(): { +export function useSpectroscopyData(kind: PlotKind = "cells"): { data: DataChannels; running: boolean; } { @@ -195,15 +273,11 @@ export function useSpectroscopyData(): { async function poll(): Promise { if (!uuid) return 0; try { - const resp = await fetchData(uuid); + const resp = await (kind === "cells" + ? fetchData(uuid) + : fetchRender(uuid, kind)); if ("notReady" in resp) return resp.retryAfterMs; // the next poll tries again - setData({ - 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)), - }); + setData(toChannels(resp)); } catch (err) { console.error("Polling error:", err); } @@ -240,7 +314,7 @@ export function useSpectroscopyData(): { pollInterval.current = null; } }; - }, [running, uuid]); + }, [running, uuid, kind]); return { data, running }; } From 1738c9266d9654c82209099ef9e718b0f67f0725 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Wed, 14 Oct 2026 14:41:04 +0100 Subject: [PATCH 5/9] ViSR: warn when footprints use a stand-in beam size, and name plot modes by source Gaussian and footprints are drawn with the beam size the run records, but no run records one yet, so /render falls back to one scan step. The plots now say so ("not the real beamline footprint"), with the size used, whenever /render reports that fallback. The modes are now named for what they are drawn from: "Setpoint grid" (the spec's setpoints, was "Cells") and "Readbacks" (the recorded positions, was "Rectangles"). The mode list and the warning text move to plotKinds.ts, so they can be tested without loading the plot library. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/SpectroscopyPlots.tsx | 38 ++++++------------ .../components/spectroscopy/plotKinds.test.ts | 24 +++++++++++ .../src/components/spectroscopy/plotKinds.ts | 40 +++++++++++++++++++ .../spectroscopy/useSpectroscopyData.ts | 11 +++++ 4 files changed, 88 insertions(+), 25 deletions(-) create mode 100644 apps/visr/src/components/spectroscopy/plotKinds.test.ts create mode 100644 apps/visr/src/components/spectroscopy/plotKinds.ts diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index 59a8d867..ae2b6fa4 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -1,7 +1,14 @@ import { ImagePlot } from "@diamondlightsource/davidia"; import { useSpectroscopyData, type PlotKind } from "./useSpectroscopyData"; +import { beamWarning, PLOT_KINDS } from "./plotKinds"; import { useMemo, useState, type ComponentProps } from "react"; -import { Box, ToggleButton, ToggleButtonGroup, Tooltip } from "@mui/material"; +import { + Alert, + Box, + ToggleButton, + ToggleButtonGroup, + Tooltip, +} from "@mui/material"; const CHANNELS = [ { key: "red", label: "Red channel" }, @@ -9,30 +16,6 @@ const CHANNELS = [ { key: "blue", label: "Blue channel" }, ] as const; -/** The ways a scan can be drawn, with what each shows */ -const PLOT_KINDS: { kind: PlotKind; label: string; help: string }[] = [ - { - kind: "cells", - label: "Cells", - help: "The scan's grid cells, each the mean of the points in it", - }, - { - kind: "rectangles", - label: "Rectangles", - help: "A step-sized rectangle on each recorded position", - }, - { - 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", - }, -]; - interface SpectroscopyPlotsProps { expanded: boolean; plotAspectRatio: ComponentProps["aspect"]; @@ -100,6 +83,11 @@ function SpectroscopyPlots({ ))} + {beamWarning(kind, channels.beam) && ( + + {beamWarning(kind, channels.beam)} + + )} { + 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/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 2342dc51..40edb1e7 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -10,8 +10,17 @@ 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; } +/** 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 | null)[][]; @@ -156,6 +165,7 @@ type RenderData = { >; coverage: number[][]; outline?: number[][]; + beam?: Beam; }; /** The JSON at `url`, or NotReady on a 503 (waiting as Retry-After asks, 1 s by default) */ @@ -215,6 +225,7 @@ export function toChannels(resp: SpectroscopyData | RenderData): DataChannels { 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, }; } return { From d4d5164e30df63e4cb07dcb6b75bbebbbf349ec8 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Wed, 14 Oct 2026 22:51:47 +0100 Subject: [PATCH 6/9] ViSR: show how far the scan has got /binned and /render both say how many points the scan has recorded and how many it will have. A bar above the plots now shows that ("120 of 289 points"), or just the count when the run has no spec. When the readbacks were shifted onto the setpoint grid, the shift is shown next to it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/SpectroscopyPlots.tsx | 63 +++++++++++++++---- .../components/spectroscopy/progress.test.ts | 26 ++++++++ .../src/components/spectroscopy/progress.ts | 20 ++++++ .../spectroscopy/useSpectroscopyData.test.ts | 33 ++++++++++ .../spectroscopy/useSpectroscopyData.ts | 36 ++++++++++- 5 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 apps/visr/src/components/spectroscopy/progress.test.ts create mode 100644 apps/visr/src/components/spectroscopy/progress.ts diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index ae2b6fa4..aa46a4a5 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -1,13 +1,17 @@ import { ImagePlot } from "@diamondlightsource/davidia"; import { useSpectroscopyData, type PlotKind } from "./useSpectroscopyData"; import { beamWarning, PLOT_KINDS } from "./plotKinds"; +import { describeOffset, describeProgress, percentDone } from "./progress"; import { useMemo, useState, type ComponentProps } from "react"; import { Alert, Box, + LinearProgress, + Stack, ToggleButton, ToggleButtonGroup, Tooltip, + Typography, } from "@mui/material"; const CHANNELS = [ @@ -27,6 +31,8 @@ function SpectroscopyPlots({ }: SpectroscopyPlotsProps) { const [kind, setKind] = useState("cells"); const { data: channels } = useSpectroscopyData(kind); + const progress = channels.progress; + const percent = progress && percentDone(progress); const plots = useMemo( () => @@ -69,20 +75,51 @@ function SpectroscopyPlots({ gap: 1, }} > - value && setKind(value)} - aria-label="How the scan is drawn" - sx={{ alignSelf: "center" }} + - {PLOT_KINDS.map(({ kind, label, help }) => ( - - {label} - - ))} - + value && setKind(value)} + aria-label="How the scan is drawn" + > + {PLOT_KINDS.map(({ kind, label, help }) => ( + + {label} + + ))} + + + {progress && ( + + {percent !== null && ( + + )} + + {describeProgress(progress)} + + {describeOffset(progress) && ( + + · {describeOffset(progress)} + + )} + + )} {beamWarning(kind, channels.beam) && ( {beamWarning(kind, channels.beam)} 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/useSpectroscopyData.test.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts index 308aa28b..17e0fb3e 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts @@ -185,3 +185,36 @@ describe("toChannels", () => { expect(channels.red.shape).toEqual([2, 2, 3]); }); }); + +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 40edb1e7..968a42d6 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -12,8 +12,27 @@ export interface DataChannels { 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; +}; + +/** 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; @@ -30,7 +49,7 @@ type SpectroscopyData = { x_limits: number[]; /** Y bin edges with size of the above datasets + 1 */ y_limits: number[]; -}; +} & ProgressFields; export type RGBColour = "red" | "green" | "blue" | "gray"; @@ -166,7 +185,7 @@ type RenderData = { coverage: number[][]; outline?: number[][]; beam?: Beam; -}; +} & ProgressFields; /** The JSON at `url`, or NotReady on a 503 (waiting as Retry-After asks, 1 s by default) */ async function fetchJson(url: string): Promise { @@ -210,8 +229,19 @@ function pixelEdges(start: number, stop: number, count: number): number[] { ); } +/** 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 = { @@ -226,6 +256,7 @@ export function toChannels(resp: SpectroscopyData | RenderData): DataChannels { xValues: binEdgesToAxis(pixelEdges(v.x0, v.x1, v.width)), yValues: binEdgesToAxis(pixelEdges(v.y0, v.y1, v.height)), beam: resp.beam, + progress, }; } return { @@ -234,6 +265,7 @@ export function toChannels(resp: SpectroscopyData | RenderData): DataChannels { blue: toRgbNdt(resp.BlueTotal, "blue"), xValues: binEdgesToAxis(splitEdges(resp.x_limits)), yValues: binEdgesToAxis(splitEdges(resp.y_limits)), + progress, }; } From bcd6a45ca6bf8a0807147b5e501f100dffd119b8 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Wed, 14 Oct 2026 22:52:19 +0100 Subject: [PATCH 7/9] ViSR: pick a region of the scan to see in full detail "Pick region" lets a rectangle be dragged on any of the plots; all three then show just that part of the scan, drawn by /render for that viewport at its full resolution and scaled over that part's own range, in whichever mode is chosen. The region is shown as a chip above the plots; removing it, or a new scan starting, goes back to the whole scan. Polling carries on over the region while the scan runs. The rectangle is drawn with Davidia's own region selection, switched on from inside the plot by RegionSelectMode (the plot's toolbar children, which is the only place the plot's interaction mode can be set). The toolbar itself stays hidden. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/RegionSelectMode.tsx | 32 ++++++++ .../spectroscopy/SpectroscopyPlots.tsx | 80 +++++++++++++++++-- .../components/spectroscopy/region.test.ts | 38 +++++++++ .../src/components/spectroscopy/region.ts | 26 ++++++ .../spectroscopy/useSpectroscopyData.test.ts | 23 ++++++ .../spectroscopy/useSpectroscopyData.ts | 38 ++++++--- 6 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 apps/visr/src/components/spectroscopy/RegionSelectMode.tsx create mode 100644 apps/visr/src/components/spectroscopy/region.test.ts create mode 100644 apps/visr/src/components/spectroscopy/region.ts diff --git a/apps/visr/src/components/spectroscopy/RegionSelectMode.tsx b/apps/visr/src/components/spectroscopy/RegionSelectMode.tsx new file mode 100644 index 00000000..7e218eea --- /dev/null +++ b/apps/visr/src/components/spectroscopy/RegionSelectMode.tsx @@ -0,0 +1,32 @@ +import { + InteractionModeType, + SelectionType, + usePlotCustomizationContext, +} from "@diamondlightsource/davidia"; +import { useEffect } from "react"; + +/** + * Lets a rectangle be drawn on the plot it is rendered in (as the plot's + * toolbar children) while `active`, and otherwise pans and zooms as usual, + * clearing the drawn rectangle. It draws nothing itself. + */ +function RegionSelectMode({ active }: { active: boolean }) { + const { setMode, setSelectionType, setSelections } = + usePlotCustomizationContext(); + + useEffect(() => { + if (active) { + setSelectionType(SelectionType.rectangle); + setMode(InteractionModeType.selectRegion); + } else { + setMode(InteractionModeType.panAndWheelZoom); + setSelections([]); + } + // the setters change identity with the plot's state; only `active` matters + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]); + + return null; +} + +export default RegionSelectMode; diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index aa46a4a5..b3ad965a 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -1,11 +1,27 @@ -import { ImagePlot } from "@diamondlightsource/davidia"; -import { useSpectroscopyData, type PlotKind } from "./useSpectroscopyData"; +import { + ImagePlot, + type SelectionsEventListener, +} from "@diamondlightsource/davidia"; +import { + useSpectroscopyData, + type PlotKind, + type Region, +} from "./useSpectroscopyData"; import { beamWarning, PLOT_KINDS } from "./plotKinds"; +import { describeRegion, selectionToRegion } from "./region"; import { describeOffset, describeProgress, percentDone } from "./progress"; -import { useMemo, useState, type ComponentProps } from "react"; +import RegionSelectMode from "./RegionSelectMode"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ComponentProps, +} from "react"; import { Alert, Box, + Chip, LinearProgress, Stack, ToggleButton, @@ -13,6 +29,7 @@ import { Tooltip, Typography, } from "@mui/material"; +import CropFreeIcon from "@mui/icons-material/CropFree"; const CHANNELS = [ { key: "red", label: "Red channel" }, @@ -30,10 +47,35 @@ function SpectroscopyPlots({ plotAspectRatio, }: SpectroscopyPlotsProps) { const [kind, setKind] = useState("cells"); - const { data: channels } = useSpectroscopyData(kind); + const [region, setRegion] = useState(); + const [picking, setPicking] = useState(false); + const { data: channels, uuid } = useSpectroscopyData(kind, region); const progress = channels.progress; const percent = progress && percentDone(progress); + // a new scan starts on the whole of it + useEffect(() => { + setRegion(undefined); + setPicking(false); + }, [uuid]); + + /** A rectangle drawn on any of the plots becomes the region all of them show */ + const onSelection = useCallback( + (type, dragging, selection) => { + // SelectionsEventType is only declared, not exported, so by its value + if (String(type) !== "created" || dragging || !selection) { + return; + } + const picked = selectionToRegion(selection); + // Davidia calls this while it renders, so update after that + queueMicrotask(() => { + if (picked) setRegion(picked); + setPicking(false); + }); + }, + [], + ); + const plots = useMemo( () => CHANNELS.map(({ key, label }) => ( @@ -46,6 +88,8 @@ function SpectroscopyPlots({ // davidia wraps every plot in a hardcoded
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 ( @@ -94,6 +140,28 @@ function SpectroscopyPlots({ ))} + + setPicking(p => !p)} + > + + Pick region + + + {region && ( + + setRegion(undefined)} + /> + + )} {progress && ( 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 index 17e0fb3e..5b99e1d5 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts @@ -2,6 +2,7 @@ import { binEdgesToAxis, CELL_PIXELS, fetchData, + fetchRender, toChannels, EMPTY_STRIPE_GREYS, splitEdges, @@ -186,6 +187,28 @@ describe("toChannels", () => { }); }); +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({ diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 968a42d6..67a50cd8 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -26,6 +26,9 @@ export type Progress = { 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; @@ -211,14 +214,19 @@ export function fetchData(uuid: string): Promise { return fetchJson(`/api/data/binned/${uuid}`); } -/** A run drawn by /render over the scan's extent, or NotReady */ +/** A run drawn by /render over `region` (default: the scan's extent), or NotReady */ export function fetchRender( uuid: string, - kind: Exclude, + kind: PlotKind, + region?: Region, ): Promise { - return fetchJson( - `/api/data/render/${uuid}?kind=${kind}&size=${RENDER_SIZE}`, - ); + 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 */ @@ -275,9 +283,19 @@ const FINAL_POLL_ATTEMPTS = 10; // initial axes must have min three points... const initialAxes = binEdgesToAxis(splitEdges([-0.25, 0.25, 0.5, 0.75])); -export function useSpectroscopyData(kind: PlotKind = "cells"): { +/** + * 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); @@ -316,9 +334,9 @@ export function useSpectroscopyData(kind: PlotKind = "cells"): { async function poll(): Promise { if (!uuid) return 0; try { - const resp = await (kind === "cells" + const resp = await (kind === "cells" && !region ? fetchData(uuid) - : fetchRender(uuid, kind)); + : fetchRender(uuid, kind, region)); if ("notReady" in resp) return resp.retryAfterMs; // the next poll tries again setData(toChannels(resp)); } catch (err) { @@ -357,7 +375,7 @@ export function useSpectroscopyData(kind: PlotKind = "cells"): { pollInterval.current = null; } }; - }, [running, uuid, kind]); + }, [running, uuid, kind, region]); - return { data, running }; + return { data, running, uuid }; } From a477abd742fd8cc926d9a9bdb0144574db106a76 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Thu, 15 Oct 2026 11:48:12 +0100 Subject: [PATCH 8/9] ViSR: drag the line under the plots to set their height The line between the plots and the controls is now a handle: dragging it sets the plots' height (150 px at least), arrow keys move it 20 px at a time when it has focus, and a double click or Escape goes back to the automatic height. The height chosen is remembered in the browser. HeightHandle is a separate component, so other views can use it too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../visr/src/components/HeightHandle.test.tsx | 61 ++++++++++++ apps/visr/src/components/HeightHandle.tsx | 97 +++++++++++++++++++ .../spectroscopy/SpectroscopyPlots.tsx | 13 ++- .../spectroscopy/SpectroscopyView.tsx | 37 ++++++- 4 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 apps/visr/src/components/HeightHandle.test.tsx create mode 100644 apps/visr/src/components/HeightHandle.tsx diff --git a/apps/visr/src/components/HeightHandle.test.tsx b/apps/visr/src/components/HeightHandle.test.tsx new file mode 100644 index 00000000..0252831b --- /dev/null +++ b/apps/visr/src/components/HeightHandle.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@atlas/vitest-conf"; +import HeightHandle from "./HeightHandle"; + +/** A handle under a box that currently measures `height` pixels */ +function setup(height: number, value: number | null = null) { + const onChange = vi.fn(); + const box = document.createElement("div"); + box.getBoundingClientRect = () => ({ height }) as DOMRect; + render( + , + ); + return { onChange, handle: screen.getByRole("separator") }; +} + +// jsdom has no PointerEvent, so pointer events would lose their coordinates +class TestPointerEvent extends MouseEvent { + pointerId: number; + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 0; + } +} + +describe("HeightHandle", () => { + beforeAll(() => vi.stubGlobal("PointerEvent", TestPointerEvent)); + afterAll(() => vi.unstubAllGlobals()); + + it("sets the height by how far it is dragged", () => { + const { onChange, handle } = setup(300); + handle.setPointerCapture = () => {}; + handle.releasePointerCapture = () => {}; + fireEvent.pointerDown(handle, { clientY: 400, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 450, pointerId: 1 }); + expect(onChange).toHaveBeenLastCalledWith(350); + fireEvent.pointerMove(handle, { clientY: 0, pointerId: 1 }); + expect(onChange).toHaveBeenLastCalledWith(100); // no lower than min + fireEvent.pointerUp(handle, { pointerId: 1 }); + fireEvent.pointerMove(handle, { clientY: 500, pointerId: 1 }); + expect(onChange).toHaveBeenCalledTimes(2); // not after letting go + }); + + it("moves with the arrow keys, and Escape or a double click resets it", () => { + const { onChange, handle } = setup(300, 300); + fireEvent.keyDown(handle, { key: "ArrowDown" }); + expect(onChange).toHaveBeenLastCalledWith(320); + fireEvent.keyDown(handle, { key: "ArrowUp" }); + expect(onChange).toHaveBeenLastCalledWith(280); + fireEvent.keyDown(handle, { key: "Escape" }); + expect(onChange).toHaveBeenLastCalledWith(null); + fireEvent.doubleClick(handle); + expect(onChange).toHaveBeenCalledTimes(4); + expect(handle).toHaveAttribute("aria-valuenow", "300"); + }); +}); diff --git a/apps/visr/src/components/HeightHandle.tsx b/apps/visr/src/components/HeightHandle.tsx new file mode 100644 index 00000000..e2f25876 --- /dev/null +++ b/apps/visr/src/components/HeightHandle.tsx @@ -0,0 +1,97 @@ +import { Box } from "@mui/material"; +import { useRef, type RefObject } from "react"; + +interface HeightHandleProps { + /** The element whose height the handle sets (it sits on its bottom edge) */ + target: RefObject; + /** The height set, or null while it is automatic */ + value: number | null; + /** The new height in pixels, or null to go back to the automatic height */ + onChange: (height: number | null) => void; + min?: number; + max?: number; + label: string; +} + +/** Pixels moved by each arrow key press */ +const KEY_STEP = 20; + +/** + * A horizontal line that can be dragged up and down to set the height of the + * element above it. Arrow keys move it too, and a double click (or Escape) + * goes back to the automatic height. + */ +function HeightHandle({ + target, + value, + onChange, + min = 150, + max = 2000, + label, +}: HeightHandleProps) { + const drag = useRef<{ y: number; height: number } | null>(null); + const clamp = (h: number) => Math.round(Math.min(max, Math.max(min, h))); + const current = () => target.current?.getBoundingClientRect().height ?? min; + + return ( + { + e.currentTarget.setPointerCapture(e.pointerId); + drag.current = { y: e.clientY, height: current() }; + e.preventDefault(); // no text selection while dragging + }} + onPointerMove={e => { + if (!drag.current) return; + onChange(clamp(drag.current.height + e.clientY - drag.current.y)); + }} + onPointerUp={e => { + e.currentTarget.releasePointerCapture(e.pointerId); + drag.current = null; + }} + onDoubleClick={() => onChange(null)} + onKeyDown={e => { + const step = { ArrowUp: -KEY_STEP, ArrowDown: KEY_STEP }[e.key]; + if (step) { + onChange(clamp(current() + step)); + e.preventDefault(); + } else if (e.key === "Escape") { + onChange(null); + } + }} + sx={{ + // straddles the line below it, so it is easy to grab + height: 10, + my: "-5px", + position: "relative", + zIndex: 1, + flexShrink: 0, + cursor: "row-resize", + touchAction: "none", + "&::after": { + content: '""', + position: "absolute", + left: 0, + right: 0, + top: 4, + height: 2, + bgcolor: "transparent", + transition: "background-color 0.15s", + }, + "&:hover::after, &:focus-visible::after, &:active::after": { + bgcolor: "primary.main", + }, + outline: "none", + }} + /> + ); +} + +export default HeightHandle; diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index b3ad965a..9ed517a5 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -17,6 +17,7 @@ import { useMemo, useState, type ComponentProps, + type Ref, } from "react"; import { Alert, @@ -40,11 +41,17 @@ const CHANNELS = [ interface SpectroscopyPlotsProps { expanded: boolean; plotAspectRatio: ComponentProps["aspect"]; + /** A height chosen by dragging (px), else the plots take the space left */ + height?: number | null; + /** The plots' outer box, for measuring it */ + containerRef?: Ref; } function SpectroscopyPlots({ expanded, plotAspectRatio, + height, + containerRef, }: SpectroscopyPlotsProps) { const [kind, setKind] = useState("cells"); const [region, setRegion] = useState(); @@ -111,11 +118,13 @@ function SpectroscopyPlots({ return ( 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" /> Date: Thu, 15 Oct 2026 14:34:49 +0100 Subject: [PATCH 9/9] ViSR: show tomography reconstructions from Tiled The tomography view now finds reconstructions in Tiled's `analysis` container (array nodes with metadata `visr.kind` set to `tomography-reconstruction`), shows the newest, and reads its bytes through /api/data. A picker in the controls switches between them. The viewer takes 3D uint8 volumes, as the reconstruction workflow writes. The mocks serve the test volume through the same Tiled endpoints. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../tomography/ReconstructionSelect.tsx | 40 ++++++ .../components/tomography/TomographyView.tsx | 68 +++++++--- .../tomography/reconstruction.test.ts | 118 ++++++++++++++++++ .../components/tomography/reconstruction.ts | 89 +++++++++++++ apps/visr/src/mocks/handlers.ts | 33 ++++- 5 files changed, 328 insertions(+), 20 deletions(-) create mode 100644 apps/visr/src/components/tomography/ReconstructionSelect.tsx create mode 100644 apps/visr/src/components/tomography/reconstruction.test.ts create mode 100644 apps/visr/src/components/tomography/reconstruction.ts 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 => ( + + {r.label} + + ))} + + + ); +} 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" }, + }); + }), ];