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/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 e3b3fc54..9ed517a5 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -1,7 +1,36 @@ -import { ImagePlot } from "@diamondlightsource/davidia"; -import { useSpectroscopyData } from "./useSpectroscopyData"; -import { useMemo, type ComponentProps } from "react"; -import { Box } from "@mui/material"; +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 RegionSelectMode from "./RegionSelectMode"; +import { + useCallback, + useEffect, + useMemo, + useState, + type ComponentProps, + type Ref, +} from "react"; +import { + Alert, + Box, + Chip, + LinearProgress, + Stack, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from "@mui/material"; +import CropFreeIcon from "@mui/icons-material/CropFree"; const CHANNELS = [ { key: "red", label: "Red channel" }, @@ -12,13 +41,47 @@ 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 { data: channels } = useSpectroscopyData(); + const [kind, setKind] = useState("cells"); + 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( () => @@ -32,6 +95,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 ( - {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 => ( + + {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" }, + }); + }), ];