From 65969f5f4a60a39e0a824625e0c686c2233edbb2 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Mon, 19 Oct 2026 14:14:42 +0100 Subject: [PATCH 1/6] 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 | 61 +++++++++++++++---- 2 files changed, 94 insertions(+), 11 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..c419a036 --- /dev/null +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts @@ -0,0 +1,44 @@ +import { fetchData } from "./useSpectroscopyData"; + +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 a50e62bb..afea9d97 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"; @@ -94,13 +94,34 @@ export function binEdgesToCentrePoints(edges: number[]): NDT { return ndarray(centres, [centres.length]); } -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 = binEdgesToCentrePoints([-0.25, 0.25, 0.5, 0.75]); @@ -143,10 +164,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({ cameraRed: toRgbNdt(resp.RedTotal, "red"), cameraGreen: toRgbNdt(resp.GreenTotal, "green"), @@ -160,20 +185,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 bfe4724ae795ab01b858d463fcc02407a6effd12 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Mon, 19 Oct 2026 14:35:31 +0100 Subject: [PATCH 2/6] ViSR: show the scan's status in a line under the plots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quiet line at the bottom right of the plots says where the plotted scan is: "Scanning…", then "Scan complete", which fades out after a few seconds. While the final poll waits on a 503 it says the last points are loading, and if they never arrive, or the scan failed, it says so in the warning or error colour and stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/SpectroscopyPlots.tsx | 125 ++++++++++++++---- .../src/components/spectroscopy/scanStatus.ts | 27 ++++ .../spectroscopy/useSpectroscopyData.ts | 33 +++-- 3 files changed, 153 insertions(+), 32 deletions(-) create mode 100644 apps/visr/src/components/spectroscopy/scanStatus.ts diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index 084b08df..e22d5885 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -1,9 +1,20 @@ -import { Box, Typography } from "@mui/material"; +import { Box, CircularProgress, Fade, Typography } from "@mui/material"; import { ImagePlot } from "@diamondlightsource/davidia"; import { ReactGridLayout, useContainerWidth } from "react-grid-layout"; -import { useLayoutEffect, useMemo, useState, type ComponentProps } from "react"; +import { + useEffect, + useLayoutEffect, + useMemo, + useState, + type ComponentProps, +} from "react"; import { useSpectroscopyData, type DataChannels } from "./useSpectroscopyData"; import { DataSource } from "./dataSource"; +import { + SCAN_STATUS_BUSY, + SCAN_STATUS_TEXT, + type ScanStatus, +} from "./scanStatus"; const CHANNELS = [ { key: "red", label: "Red channel" }, @@ -48,6 +59,62 @@ function selectChannels( }; } +// --------------------------------------------------------------------------- +// Scan status: one quiet line under the plots, at the right +// --------------------------------------------------------------------------- + +const STATUS_COLOUR: Record = { + scanning: "text.secondary", + loadingLastPoints: "text.secondary", + complete: "text.secondary", + lastPointsMissing: "warning.main", + failed: "error.main", +}; + +/** "Scan complete" fades out after this long; problems stay */ +const COMPLETE_SHOWN_MS = 3000; +const FADE_MS = 1000; + +/** One status's text; remounted when the status changes, so a fade starts afresh */ +function ScanStatusText({ status }: { status: ScanStatus }) { + const [shown, setShown] = useState(true); + useEffect(() => { + if (status !== "complete") return; + const timer = setTimeout(() => setShown(false), COMPLETE_SHOWN_MS); + return () => clearTimeout(timer); + }, [status]); + return ( + + + {SCAN_STATUS_BUSY.has(status) && ( + + )} + + {SCAN_STATUS_TEXT[status]} + + + + ); +} + +function ScanStatusLine({ status }: { status: ScanStatus | null }) { + return ( + + {status && } + + ); +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -63,7 +130,7 @@ function SpectroscopyPlots({ expanded, plotAspectRatio, }: SpectroscopyPlotsProps) { - const { data } = useSpectroscopyData(); + const { data, status } = useSpectroscopyData(); const channels = useMemo( () => selectChannels(data, dataSource), [data, dataSource], @@ -183,32 +250,44 @@ function SpectroscopyPlots({ // ------------------------------------------------------------------------- return ( } sx={{ flex: "1 1 0", + display: "flex", + flexDirection: "column", width: "100%", minWidth: 0, - height: gridHeight, - minHeight: minimumGridHeight, - overflow: "visible", + minHeight: 0, }} > - {mounted && ( - {}} - > - {plots} - - )} + } + sx={{ + flex: "1 1 0", + width: "100%", + minWidth: 0, + height: gridHeight, + minHeight: minimumGridHeight, + overflow: "visible", + }} + > + {mounted && ( + {}} + > + {plots} + + )} + + ); } diff --git a/apps/visr/src/components/spectroscopy/scanStatus.ts b/apps/visr/src/components/spectroscopy/scanStatus.ts new file mode 100644 index 00000000..a5075688 --- /dev/null +++ b/apps/visr/src/components/spectroscopy/scanStatus.ts @@ -0,0 +1,27 @@ +/** + * Where the plotted scan is: from the scan events, then from the poll for its + * last points once it has ended. + */ +export type ScanStatus = + | "scanning" + /** The scan ended; the server isn't ready with its last points yet */ + | "loadingLastPoints" + | "complete" + /** The scan ended, but its last points couldn't be fetched */ + | "lastPointsMissing" + | "failed"; + +export const SCAN_STATUS_TEXT: Record = { + scanning: "Scanning…", + loadingLastPoints: "Scan complete, loading the last points…", + complete: "Scan complete", + lastPointsMissing: + "Scan complete. Some of the last points could not be loaded", + failed: "Scan failed", +}; + +/** Statuses that will change on their own, shown with a spinner */ +export const SCAN_STATUS_BUSY: ReadonlySet = new Set([ + "scanning", + "loadingLastPoints", +]); diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index afea9d97..6a914d32 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -2,6 +2,7 @@ import type { NDT } from "@diamondlightsource/davidia"; import ndarray from "ndarray"; import { useEffect, useRef, useState } from "react"; import { useScanEvents } from "../../hooks/scanEvents/useScanEvents"; +import type { ScanStatus } from "./scanStatus"; /** SpectroscopyData wrapped as NDT for ease of Davidia plotting */ export interface DataChannels { @@ -122,15 +123,20 @@ export async function fetchData( /** Tries for the final image of a scan, when a 503 isn't followed by another poll */ const FINAL_POLL_ATTEMPTS = 10; +/** What one poll got: the data, a failure, or the wait the server asked for */ +type PollResult = "done" | "error" | { retryAfterMs: number }; + // initial axes must have min three points... const initialAxes = binEdgesToCentrePoints([-0.25, 0.25, 0.5, 0.75]); export function useSpectroscopyData(): { data: DataChannels; running: boolean; + status: ScanStatus | null; } { const scanEvent = useScanEvents(); const [running, setRunning] = useState(false); + const [status, setStatus] = useState(null); const [uuid, setUuid] = useState(null); const [data, setData] = useState({ cameraRed: EMPTY_NDT, @@ -154,11 +160,13 @@ export function useSpectroscopyData(): { if (scanEvent.status === "running") { setRunning(true); setUuid(scanEvent.uuid); + setStatus("scanning"); } else if ( scanEvent.status === "finished" || scanEvent.status === "failed" ) { setRunning(false); // triggers final poll below + if (scanEvent.status === "failed") setStatus("failed"); } }, [scanEvent]); @@ -166,12 +174,11 @@ export function useSpectroscopyData(): { useEffect(() => { 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; + async function poll(): Promise { + if (!uuid) return "error"; try { const resp = await fetchData(uuid); - if ("notReady" in resp) return resp.retryAfterMs; // the next poll tries again + if ("notReady" in resp) return resp; // the next poll tries again setData({ cameraRed: toRgbNdt(resp.RedTotal, "red"), cameraGreen: toRgbNdt(resp.GreenTotal, "green"), @@ -184,21 +191,29 @@ export function useSpectroscopyData(): { }); } catch (err) { console.error("Polling error:", err); + return "error"; } - return 0; + return "done"; } /** The last poll after a scan ends: no later poll will follow a 503, so retry */ async function finalPoll() { + // a failed scan stays "failed", whatever its last points do + const settle = (next: ScanStatus) => + setStatus(current => (current === "failed" ? current : next)); for ( let attempt = 0; attempt < FINAL_POLL_ATTEMPTS && !cancelled; attempt++ ) { - const wait = await poll(); - if (!wait) return; - await new Promise(resolve => setTimeout(resolve, wait)); + const result = await poll(); + if (cancelled) return; + if (result === "done") return settle("complete"); + if (result === "error") return settle("lastPointsMissing"); + settle("loadingLastPoints"); + await new Promise(resolve => setTimeout(resolve, result.retryAfterMs)); } + if (!cancelled) settle("lastPointsMissing"); } if (running && uuid) { @@ -220,5 +235,5 @@ export function useSpectroscopyData(): { }; }, [running, uuid]); - return { data, running }; + return { data, running, status }; } From 104b1ee8a93106991eb39dcff57f8c1e1b356c59 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Mon, 19 Oct 2026 14:46:08 +0100 Subject: [PATCH 3/6] ViSR: draw unmeasured cells as faint stripes, each cell between its edges 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 faint diagonal stripes, one in the colour of the page behind the plots and one a shade darker on a light page or lighter on a dark one, so they recede in either theme. Each cell is drawn as 4x4 pixels to carry the pattern. A run without diodes shows its diode plots as all stripes, the camera's shape. 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. The plots were drawn in pixel numbers, without axes. They now get /binned's bin edges as axes, so each cell sits between its own edges in scan coordinates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/SpectroscopyPlots.tsx | 67 ++++- .../spectroscopy/useSpectroscopyData.test.ts | 150 ++++++++++- .../spectroscopy/useSpectroscopyData.ts | 248 +++++++++++++----- 3 files changed, 387 insertions(+), 78 deletions(-) diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index e22d5885..57458626 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -1,4 +1,10 @@ -import { Box, CircularProgress, Fade, Typography } from "@mui/material"; +import { + Box, + CircularProgress, + Fade, + Typography, + useColorScheme, +} from "@mui/material"; import { ImagePlot } from "@diamondlightsource/davidia"; import { ReactGridLayout, useContainerWidth } from "react-grid-layout"; import { @@ -8,7 +14,14 @@ import { useState, type ComponentProps, } from "react"; -import { useSpectroscopyData, type DataChannels } from "./useSpectroscopyData"; +import { + DEFAULT_STRIPES, + stripesOn, + useSpectroscopyData, + type DataChannels, + type Rgb, + type StripeColours, +} from "./useSpectroscopyData"; import { DataSource } from "./dataSource"; import { SCAN_STATUS_BUSY, @@ -115,6 +128,47 @@ function ScanStatusLine({ status }: { status: ScanStatus | null }) { ); } +// --------------------------------------------------------------------------- +// Empty-cell stripes in the colours of the page behind the plots +// --------------------------------------------------------------------------- + +/** The first opaque background colour at or above `element`, if any */ +function backgroundBehind(element: HTMLElement | null): Rgb | null { + for (let el = element; el; el = el.parentElement) { + const parts = getComputedStyle(el).backgroundColor.match(/[\d.]+/g); + if (parts && parts.length >= 3 && (parts.length < 4 || +parts[3] > 0)) { + return [+parts[0], +parts[1], +parts[2]]; + } + } + return null; +} + +/** + * Stripes matching the background behind `element`, read again when the + * colour scheme changes (after the browser has applied it) + */ +function useStripesBehind( + element: React.RefObject, +): StripeColours { + const { mode, systemMode } = useColorScheme(); + const scheme = mode === "system" ? systemMode : mode; + const [background, setBackground] = useState(null); + useEffect(() => { + const frame = requestAnimationFrame(() => { + const found = backgroundBehind(element.current); + if (found) setBackground(found.join(",")); + }); + return () => cancelAnimationFrame(frame); + }, [element, scheme]); + return useMemo( + () => + background + ? stripesOn(background.split(",").map(Number) as unknown as Rgb) + : DEFAULT_STRIPES, + [background], + ); +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -130,12 +184,15 @@ function SpectroscopyPlots({ expanded, plotAspectRatio, }: SpectroscopyPlotsProps) { - const { data, status } = useSpectroscopyData(); + const { width, containerRef, mounted } = useContainerWidth(); + const stripes = useStripesBehind( + containerRef as React.RefObject, + ); + const { data, status } = useSpectroscopyData(stripes); const channels = useMemo( () => selectChannels(data, dataSource), [data, dataSource], ); - const { width, containerRef, mounted } = useContainerWidth(); // ------------------------------------------------------------------------- // Grid sizing @@ -193,7 +250,7 @@ function SpectroscopyPlots({ return ( 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 colours = new Set(empty.map(p => p.join())); + expect([...colours].sort()).toEqual( + DEFAULT_STRIPES.map(c => c.join()).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]); + }); + + it("draws a missing channel as the empty placeholder", () => { + expect(toRgbNdt(null, "red").shape).toEqual([ + 3 * CELL_PIXELS, + 3 * CELL_PIXELS, + 3, + ]); + }); +}); + +describe("stripesOn", () => { + it("is the background and a slightly darker shade on a light page", () => { + expect(stripesOn([246, 246, 249])).toEqual([ + [246, 246, 249], + [236, 236, 239], + ]); + }); + + it("is the background and a slightly lighter shade on a dark page", () => { + expect(stripesOn([14, 16, 23])).toEqual([ + [14, 16, 23], + [24, 26, 33], + ]); + }); + + it("colours toRgbNdt's empty cells", () => { + const stripes = stripesOn([14, 16, 23]); + const image = toRgbNdt([[null]], "red", stripes); + const colours = new Set(); + for (let y = 0; y < CELL_PIXELS; y++) { + for (let x = 0; x < CELL_PIXELS; x++) { + colours.add([0, 1, 2].map(ch => image.get(y, x, ch)).join()); + } + } + expect([...colours].sort()).toEqual(stripes.map(c => c.join()).sort()); + }); +}); + +describe("orEmptyLike", () => { + it("keeps a channel that is there", () => { + expect(orEmptyLike([[1, 2]], [[3, 4]])).toEqual([[1, 2]]); + }); + + it("gives a missing channel empty cells the shape of another", () => { + expect( + orEmptyLike(null, [ + [1, null], + [3, 4], + ]), + ).toEqual([ + [null, null], + [null, null], + ]); + }); +}); + +describe("splitEdges", () => { + it("splits each bin into 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()); diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 6a914d32..368ca638 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -1,6 +1,6 @@ import type { NDT } from "@diamondlightsource/davidia"; import ndarray from "ndarray"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useScanEvents } from "../../hooks/scanEvents/useScanEvents"; import type { ScanStatus } from "./scanStatus"; @@ -18,23 +18,86 @@ export interface DataChannels { /** Return type of `/api/data/binned` */ type SpectroscopyData = { - RedTotal: number[][]; - GreenTotal: number[][]; - BlueTotal: number[][]; - DiodesRed: number[][]; - DiodesGreen: number[][]; - DiodesBlue: number[][]; + RedTotal: (number | null)[][]; + GreenTotal: (number | null)[][]; + BlueTotal: (number | null)[][]; + /** Null for a run without diodes */ + DiodesRed: (number | null)[][] | null; + DiodesGreen: (number | null)[][] | null; + DiodesBlue: (number | null)[][] | null; /** X bin edges with size of the above datasets + 1 */ x_limits: number[]; /** Y bin edges with size of the above datasets + 1 */ y_limits: number[]; }; -type RGBColour = "red" | "green" | "blue" | "gray"; +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: faint diagonal stripes, 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; + +/** An RGB colour, 0-255 each */ +export type Rgb = readonly [number, number, number]; + +/** The two colours of the empty cells' stripes */ +export type StripeColours = readonly [Rgb, Rgb]; + +/** How far the second stripe is from the background, 0-255 per channel */ +export const STRIPE_CONTRAST = 10; + +/** + * Stripes that barely stand out from the background they sit on: one in the + * background's own colour, the other slightly darker on a light background, + * slightly lighter on a dark one + */ +export function stripesOn(background: Rgb): StripeColours { + const luminance = + 0.299 * background[0] + 0.587 * background[1] + 0.114 * background[2]; + const shift = luminance > 127 ? -STRIPE_CONTRAST : STRIPE_CONTRAST; + const shifted = background.map(c => + Math.min(255, Math.max(0, c + shift)), + ) as unknown as Rgb; + return [background, shifted]; +} + +/** The Diamond theme's light background, until the page's own is known */ +export const DEFAULT_STRIPES = stripesOn([246, 246, 249]); + +function emptyColour(row: number, col: number, stripes: StripeColours): Rgb { + return stripes[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 stripes in the + * given colours. If + * every value is the same (a single point, say), they are drawn at full + * brightness rather than black. + */ +export function toRgbNdt( + matrix: (number | null)[][] | null, + colour: RGBColour, + stripes: StripeColours = DEFAULT_STRIPES, +): NDT { if (!matrix?.length || !matrix[0]?.length) { - return EMPTY_NDT; // skip invalid input + return emptyNdt(stripes); // skip invalid input } const height = matrix.length; const width = matrix[0].length; @@ -46,53 +109,78 @@ 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.set(emptyColour(y, x, stripes), i * 3); + 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 before any data: 3x3 empty cells, matching initialAxes */ +function emptyNdt(stripes: StripeColours): NDT { + return toRgbNdt( + [ + [null, null, null], + [null, null, null], + [null, null, null], + ], + "gray", + stripes, + ); +} + +/** + * A channel's matrix, or for a run without it (null), empty cells the shape of + * the camera's, so it still lines up with the axes + */ +export function orEmptyLike( + matrix: (number | null)[][] | null, + like: (number | null)[][], +): (number | null)[][] { + return matrix ?? like.map(row => row.map(() => null)); } -/** Placeholder empty gray dataset */ -const EMPTY_NDT = toRgbNdt([[0, 0, 0]], "gray"); -/** given array of edges size L, returns the centre points, size L-1, as NDT */ -export function binEdgesToCentrePoints(edges: number[]): NDT { +/** + * Bin edges as plot axis values. The plot reads one more value than there are + * pixels as the pixels' edges, so each cell sits exactly between its own edges. + * (Given cell centres instead, it reads them as each pixel's leading edge and + * shifts the image by half a cell.) + */ +export function binEdgesToAxis(edges: number[]): NDT { if (edges.length < 2) { throw new Error("At least two bin edges are required"); } - - 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; } /** /binned answered 503: the data file is behind the catalog, try again shortly */ @@ -127,9 +215,42 @@ const FINAL_POLL_ATTEMPTS = 10; type PollResult = "done" | "error" | { retryAfterMs: number }; // initial axes must have min three points... -const initialAxes = binEdgesToCentrePoints([-0.25, 0.25, 0.5, 0.75]); +const initialAxes = binEdgesToAxis(splitEdges([-0.25, 0.25, 0.5, 0.75])); + +/** /binned's answer as plot images, empty cells in the given stripes */ +function toChannels( + binned: SpectroscopyData | null, + stripes: StripeColours, +): DataChannels { + if (!binned) { + const empty = emptyNdt(stripes); + return { + cameraRed: empty, + cameraGreen: empty, + cameraBlue: empty, + diodesRed: empty, + diodesGreen: empty, + diodesBlue: empty, + xValues: initialAxes, + yValues: initialAxes, + }; + } + const image = (matrix: (number | null)[][] | null, colour: RGBColour) => + toRgbNdt(orEmptyLike(matrix, binned.RedTotal), colour, stripes); + return { + cameraRed: image(binned.RedTotal, "red"), + cameraGreen: image(binned.GreenTotal, "green"), + cameraBlue: image(binned.BlueTotal, "blue"), + diodesRed: image(binned.DiodesRed, "red"), + diodesGreen: image(binned.DiodesGreen, "green"), + diodesBlue: image(binned.DiodesBlue, "blue"), + xValues: binEdgesToAxis(splitEdges(binned.x_limits)), + yValues: binEdgesToAxis(splitEdges(binned.y_limits)), + }; +} -export function useSpectroscopyData(): { +/** The scan's binned images, polled while it runs, with empty cells in `stripes` */ +export function useSpectroscopyData(stripes: StripeColours = DEFAULT_STRIPES): { data: DataChannels; running: boolean; status: ScanStatus | null; @@ -138,17 +259,9 @@ export function useSpectroscopyData(): { const [running, setRunning] = useState(false); const [status, setStatus] = useState(null); const [uuid, setUuid] = useState(null); - const [data, setData] = useState({ - cameraRed: EMPTY_NDT, - cameraGreen: EMPTY_NDT, - cameraBlue: EMPTY_NDT, - diodesRed: EMPTY_NDT, - diodesGreen: EMPTY_NDT, - diodesBlue: EMPTY_NDT, - - xValues: initialAxes, - yValues: initialAxes, - }); + // kept as /binned answered, so a theme change redraws it without a poll + const [binned, setBinned] = useState(null); + const data = useMemo(() => toChannels(binned, stripes), [binned, stripes]); /** Cached interval id */ const pollInterval = useRef | null>(null); @@ -179,16 +292,7 @@ export function useSpectroscopyData(): { try { const resp = await fetchData(uuid); if ("notReady" in resp) return resp; // the next poll tries again - setData({ - cameraRed: toRgbNdt(resp.RedTotal, "red"), - cameraGreen: toRgbNdt(resp.GreenTotal, "green"), - cameraBlue: toRgbNdt(resp.BlueTotal, "blue"), - diodesRed: toRgbNdt(resp.DiodesRed, "red"), - diodesGreen: toRgbNdt(resp.DiodesGreen, "green"), - diodesBlue: toRgbNdt(resp.DiodesBlue, "blue"), - xValues: binEdgesToCentrePoints(resp.x_limits), - yValues: binEdgesToCentrePoints(resp.y_limits), - }); + setBinned(resp); } catch (err) { console.error("Polling error:", err); return "error"; From beff59cd143cf5c5c3bb8bfbad50bf2ff56fd949 Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Mon, 19 Oct 2026 16:03:12 +0100 Subject: [PATCH 4/6] ViSR: choose how the scan is drawn: setpoint grid, readbacks, Gaussian or footprints A Plot Type choice under Data Source picks the drawing, with a tooltip on what each shows. Setpoint grid 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 at each recorded position (Readbacks), a beam-sized Gaussian on each point, or each point's beam footprint with overlaps split between neighbours. Both data sources work in every drawing. Where /render drew nothing, or covered a pixel only partly, it fades into the empty-cell stripes, so gaps the beam left stay visible. Gaussian and footprints use the beam size the run records. No run records one yet, so /render falls back to one scan step, and a note under the plots says so, with the size used. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/SpectroscopyControls.tsx | 32 ++- .../spectroscopy/SpectroscopyPlots.tsx | 28 ++- .../spectroscopy/SpectroscopyView.tsx | 5 + .../components/spectroscopy/plotKinds.test.ts | 24 ++ .../src/components/spectroscopy/plotKinds.ts | 40 ++++ .../spectroscopy/useSpectroscopyData.test.ts | 54 +++++ .../spectroscopy/useSpectroscopyData.ts | 212 +++++++++++++----- 7 files changed, 336 insertions(+), 59 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/SpectroscopyControls.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx index c7fe7ff6..fa89d0e8 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx @@ -3,16 +3,26 @@ import { FormControlLabel, Radio, RadioGroup, + Tooltip, Typography, } from "@mui/material"; import { DataSource } from "./dataSource"; +import { PLOT_KINDS } from "./plotKinds"; +import type { PlotKind } from "./useSpectroscopyData"; interface Props { onSetDataSource: (event: React.ChangeEvent) => void; dataSource: DataSource; + onSetPlotKind: (kind: PlotKind) => void; + plotKind: PlotKind; } -export default function Controls({ onSetDataSource, dataSource }: Props) { +export default function Controls({ + onSetDataSource, + dataSource, + onSetPlotKind, + plotKind, +}: Props) { return ( + + + + Plot Type + + onSetPlotKind(event.target.value as PlotKind)} + > + {PLOT_KINDS.map(({ kind, label, help }) => ( + + } + label={label} + /> + + ))} + + ); } diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index 57458626..0bdb6091 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -19,9 +19,11 @@ import { stripesOn, useSpectroscopyData, type DataChannels, + type PlotKind, type Rgb, type StripeColours, } from "./useSpectroscopyData"; +import { beamWarning } from "./plotKinds"; import { DataSource } from "./dataSource"; import { SCAN_STATUS_BUSY, @@ -45,7 +47,7 @@ export type SpectroscopyData = Partial>; const SOURCE_CHANNELS: Record< DataSource, - Record + Record > = { [DataSource.Camera]: { red: "cameraRed", @@ -110,7 +112,14 @@ function ScanStatusText({ status }: { status: ScanStatus }) { ); } -function ScanStatusLine({ status }: { status: ScanStatus | null }) { +function ScanStatusLine({ + status, + note, +}: { + status: ScanStatus | null; + /** A remark about the plots themselves, on the left */ + note?: string | null; +}) { return ( + {note && ( + + {note} + + )} {status && } ); @@ -175,12 +193,14 @@ function useStripesBehind( interface SpectroscopyPlotsProps { dataSource: DataSource; + plotKind: PlotKind; expanded: boolean; plotAspectRatio: number | "auto" | "equal"; } function SpectroscopyPlots({ dataSource, + plotKind, expanded, plotAspectRatio, }: SpectroscopyPlotsProps) { @@ -188,7 +208,7 @@ function SpectroscopyPlots({ const stripes = useStripesBehind( containerRef as React.RefObject, ); - const { data, status } = useSpectroscopyData(stripes); + const { data, status } = useSpectroscopyData(plotKind, stripes); const channels = useMemo( () => selectChannels(data, dataSource), [data, dataSource], @@ -344,7 +364,7 @@ function SpectroscopyPlots({ )} - + ); } diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx index fcd3073f..f61f8765 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx @@ -9,6 +9,7 @@ import { SpectroscopyForm } from "./SpectroscopyForm"; import SpectroscopyControls from "./SpectroscopyControls"; import { useInstrumentSession } from "@atlas/app-shell"; import { DataSource } from "./dataSource"; +import type { PlotKind } from "./useSpectroscopyData"; export type SpectroscopyFormData = { total_number_of_scan_points: number; @@ -27,6 +28,7 @@ const PLOT_ASPECT_RATIO = "equal"; function SpectroscopyView() { const [drawerOpen, setDrawerOpen] = useState(true); const [dataSource, setDataSource] = useState(DataSource.Camera); + const [plotKind, setPlotKind] = useState("cells"); // ------------------------------------------------------------------------- // Set off workflow when scan ends @@ -71,6 +73,7 @@ function SpectroscopyView() { > @@ -82,6 +85,8 @@ function SpectroscopyView() { , ]} /> diff --git a/apps/visr/src/components/spectroscopy/plotKinds.test.ts b/apps/visr/src/components/spectroscopy/plotKinds.test.ts new file mode 100644 index 00000000..0b706518 --- /dev/null +++ b/apps/visr/src/components/spectroscopy/plotKinds.test.ts @@ -0,0 +1,24 @@ +import { beamWarning } from "./plotKinds"; + +describe("beamWarning", () => { + const step = { x: 0.25, y: 0.25, source: "scan step" } as const; + + it("warns when the beam is only the scan step, for the beam-sized drawings", () => { + expect(beamWarning("ovals", step)).toContain("0.25 × 0.25"); + expect(beamWarning("gaussian", step)).toContain( + "not the real beamline footprint", + ); + }); + + it("says nothing when the run records its beam size", () => { + expect( + beamWarning("ovals", { x: 0.1, y: 0.2, source: "metadata" }), + ).toBeNull(); + }); + + it("says nothing for drawings that don't use the beam", () => { + expect(beamWarning("cells", step)).toBeNull(); + expect(beamWarning("rectangles", step)).toBeNull(); + expect(beamWarning("ovals")).toBeNull(); + }); +}); 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.test.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts index 1c9fc460..680bf0b0 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts @@ -6,6 +6,7 @@ import { splitEdges, DEFAULT_STRIPES, stripesOn, + toChannels, toRgbNdt, } from "./useSpectroscopyData"; @@ -190,3 +191,56 @@ describe("fetchData", () => { await expect(fetchData("uid")).rejects.toThrow(); }); }); + +describe("toChannels of /render's answer", () => { + const stripes = stripesOn([246, 246, 249]); + const answer = { + viewport: { x0: 0, x1: 2, y0: 10, y1: 11, width: 2, height: 1 }, + channels: { + RedTotal: [[0, 4]], + GreenTotal: [[1, 1]], + BlueTotal: [[null, 2]], + DiodesRed: [[3, null]], + DiodesGreen: null, + DiodesBlue: null, + }, + coverage: [[1, 0.5]], + outline: [[0, 1]], + beam: { x: 1, y: 1, source: "scan step" as const }, + }; + const pixel = (image: ReturnType, x: number) => + [0, 1, 2].map(ch => image.get(0, x, ch)); + + it("draws one pixel per value, over the viewport's pixel edges", () => { + const channels = toChannels(answer, stripes); + expect(channels.cameraRed.shape).toEqual([1, 2, 3]); + expect(Array.from(channels.xValues.data as Float64Array)).toEqual([ + 0, 1, 2, + ]); + expect(Array.from(channels.yValues.data as Float64Array)).toEqual([10, 11]); + expect(channels.beam).toEqual(answer.beam); + }); + + it("fades partly covered pixels into the stripes", () => { + const channels = toChannels({ ...answer, outline: undefined }, stripes); + expect(pixel(channels.cameraRed, 0)).toEqual([0, 0, 0]); // fully covered + const [r, g] = pixel(channels.cameraRed, 1); // half 255, half its stripe + expect(r).toBe(Math.round(0.5 * 255 + 0.5 * stripes[0][0])); + expect(g).toBe(Math.round(0.5 * stripes[0][1])); + }); + + it("draws outlines in white over the pixels, if asked", () => { + const image = toRgbNdt([[0, 4]], "red", stripes, { + cellPixels: 1, + outline: answer.outline, + }); + expect(pixel(image, 0)).toEqual([0, 0, 0]); + expect(pixel(image, 1)).toEqual([255, 255, 255]); + }); + + it("draws a channel the run lacks, and empty pixels, as stripes", () => { + const channels = toChannels({ ...answer, outline: undefined }, stripes); + expect(pixel(channels.diodesGreen, 0)).toEqual([...stripes[0]]); + expect(pixel(channels.cameraBlue, 0)).toEqual([...stripes[0]]); + }); +}); diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 368ca638..7e810157 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -14,8 +14,17 @@ export interface DataChannels { diodesBlue: NDT; xValues: NDT; yValues: NDT; + /** The beam size /render drew with, and where it came from (Gaussian and footprints) */ + beam?: Beam; } +/** A beam size (FWHM, in motor units) and where /render took it from */ +export type Beam = { + x: number; + y: number; + source: "request" | "metadata" | "scan step"; +}; + /** Return type of `/api/data/binned` */ type SpectroscopyData = { RedTotal: (number | null)[][]; @@ -31,6 +40,38 @@ type SpectroscopyData = { y_limits: number[]; }; +type Matrix = (number | null)[][]; + +/** Return type of `/api/data/render` (JSON) */ +type RenderData = { + viewport: { + x0: number; + x1: number; + y0: number; + y1: number; + width: number; + height: number; + }; + channels: Record<"RedTotal" | "GreenTotal" | "BlueTotal", Matrix> & + /** Null for a run without diodes */ + Record<"DiodesRed" | "DiodesGreen" | "DiodesBlue", Matrix | null>; + coverage: number[][]; + outline?: number[][]; + beam?: Beam; +}; + +/** How the scan is drawn: /binned's cells, or one of /render's kinds */ +export type PlotKind = "cells" | "rectangles" | "gaussian" | "ovals"; + +/** + * Whether footprints are drawn with /render's white outlines; without them + * the footprints meet edge to edge + */ +export const DRAW_OUTLINES = false; + +/** Longest side, in pixels, of the images /render draws */ +export const RENDER_SIZE = 200; + export type RGBColour = "red" | "green" | "blue" | "gray"; /** @@ -84,68 +125,74 @@ 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 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 stripes in the - * given colours. 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 stripes in the + * given colours, and a partly covered cell fades into them. If every value is + * the same (a single point, say), they are drawn at full brightness rather + * than black. */ export function toRgbNdt( matrix: (number | null)[][] | null, colour: RGBColour, stripes: StripeColours = DEFAULT_STRIPES, + { cellPixels = CELL_PIXELS, coverage, outline }: RgbOptions = {}, ): NDT { if (!matrix?.length || !matrix[0]?.length) { return emptyNdt(stripes); // 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; + // the range of the values, ignoring empty cells (a loop: /render's images + // are too large to spread into Math.min) + let min = Infinity; + let max = -Infinity; + for (const v of flat) { + if (v !== null && !isNaN(v)) { + min = Math.min(min, v); + max = Math.max(max, v); + } + } const scale = max > min ? 255 / (max - min) : 0; + const lit = { red: [0], green: [1], blue: [2], gray: [0, 1, 2] }[colour]; - const 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.set(emptyColour(y, x, stripes), i * 3); - 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 empty = emptyColour(y, x, stripes); + const covered = v === null || isNaN(v) ? 0 : (coverage?.[r]?.[c] ?? 1); + const scaled = covered && max > min ? (v! - min) * scale : 255; + const line = outline?.[r]?.[c] ?? 0; + for (let k = 0; k < 3; k++) { + const value = lit.includes(k) ? scaled : 0; + const out = covered * value + (1 - covered) * empty[k]; + rgb[i + k] = Math.round(out * (1 - line) + 255 * line); } } } return ndarray(rgb, [H, W, 3]) as NDT; } + /** Placeholder before any data: 3x3 empty cells, matching initialAxes */ function emptyNdt(stripes: StripeColours): NDT { return toRgbNdt( @@ -183,18 +230,15 @@ 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. + * The JSON at `url`, or NotReady when the server says the data isn't written + * yet (503, waiting as long as its Retry-After asks, 1 s by default). Any + * other failure throws. */ -export async function fetchData( - uuid: string, -): Promise { - const url = `/api/data/binned/${uuid}`; +async function fetchJson(url: string): Promise { const resp = await fetch(url); if (resp.status === 503) { const seconds = Number(resp.headers.get("Retry-After")); @@ -208,6 +252,29 @@ export async function fetchData( return await resp.json(); // here we should use zod } +/** The binned images of a run, or NotReady */ +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, + ); +} + /** Tries for the final image of a scan, when a 503 isn't followed by another poll */ const FINAL_POLL_ATTEMPTS = 10; @@ -217,11 +284,37 @@ type PollResult = "done" | "error" | { retryAfterMs: number }; // initial axes must have min three points... const initialAxes = binEdgesToAxis(splitEdges([-0.25, 0.25, 0.5, 0.75])); -/** /binned's answer as plot images, empty cells in the given stripes */ -function toChannels( - binned: SpectroscopyData | null, +/** + * /binned's or /render's answer as plot images, empty cells in the given + * stripes. A channel the run lacks (the diodes) is drawn all empty. + */ +export function toChannels( + answer: SpectroscopyData | RenderData | null, stripes: StripeColours, ): DataChannels { + if (answer && "viewport" in answer) { + const v = answer.viewport; + const ch = answer.channels; + const options = { + cellPixels: 1, + coverage: answer.coverage, + outline: DRAW_OUTLINES ? answer.outline : undefined, + }; + const image = (matrix: Matrix | null, colour: RGBColour) => + toRgbNdt(orEmptyLike(matrix, ch.RedTotal), colour, stripes, options); + return { + cameraRed: image(ch.RedTotal, "red"), + cameraGreen: image(ch.GreenTotal, "green"), + cameraBlue: image(ch.BlueTotal, "blue"), + diodesRed: image(ch.DiodesRed, "red"), + diodesGreen: image(ch.DiodesGreen, "green"), + diodesBlue: image(ch.DiodesBlue, "blue"), + xValues: binEdgesToAxis(pixelEdges(v.x0, v.x1, v.width)), + yValues: binEdgesToAxis(pixelEdges(v.y0, v.y1, v.height)), + beam: answer.beam, + }; + } + const binned = answer; if (!binned) { const empty = emptyNdt(stripes); return { @@ -249,8 +342,14 @@ function toChannels( }; } -/** The scan's binned images, polled while it runs, with empty cells in `stripes` */ -export function useSpectroscopyData(stripes: StripeColours = DEFAULT_STRIPES): { +/** + * The scan drawn as `kind`, polled while it runs, with empty cells in + * `stripes` + */ +export function useSpectroscopyData( + kind: PlotKind = "cells", + stripes: StripeColours = DEFAULT_STRIPES, +): { data: DataChannels; running: boolean; status: ScanStatus | null; @@ -259,9 +358,11 @@ export function useSpectroscopyData(stripes: StripeColours = DEFAULT_STRIPES): { const [running, setRunning] = useState(false); const [status, setStatus] = useState(null); const [uuid, setUuid] = useState(null); - // kept as /binned answered, so a theme change redraws it without a poll - const [binned, setBinned] = useState(null); - const data = useMemo(() => toChannels(binned, stripes), [binned, stripes]); + // kept as the server answered, so a theme change redraws it without a poll + const [answer, setAnswer] = useState( + null, + ); + const data = useMemo(() => toChannels(answer, stripes), [answer, stripes]); /** Cached interval id */ const pollInterval = useRef | null>(null); @@ -290,9 +391,12 @@ export function useSpectroscopyData(stripes: StripeColours = DEFAULT_STRIPES): { async function poll(): Promise { if (!uuid) return "error"; try { - const resp = await fetchData(uuid); + const resp = await (kind === "cells" + ? fetchData(uuid) + : fetchRender(uuid, kind)); + if (cancelled) return "done"; // the kind or scan changed meanwhile if ("notReady" in resp) return resp; // the next poll tries again - setBinned(resp); + setAnswer(resp); } catch (err) { console.error("Polling error:", err); return "error"; @@ -337,7 +441,7 @@ export function useSpectroscopyData(stripes: StripeColours = DEFAULT_STRIPES): { pollInterval.current = null; } }; - }, [running, uuid]); + }, [running, uuid, kind]); return { data, running, status }; } From a55bc8ce1afd051bb5786107a1d6dc27948a4e8f Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Mon, 19 Oct 2026 16:20:16 +0100 Subject: [PATCH 5/6] ViSR: choose the positions and the plot type separately The drawing is now two choices of one line each. Positions: Setpoints (the scan's grid, each cell the mean of the points read back in it) or Readbacks (each point where the motors read back). Plot Type: Rectangles, Gaussian or Footprints. Gaussian and footprints only make sense at the readback positions, so on the setpoint grid they are greyed out and the grid's cells show as rectangles; the shape picked for readbacks is kept meanwhile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/SpectroscopyControls.tsx | 69 +++++++++++++++---- .../spectroscopy/SpectroscopyView.tsx | 13 ++-- .../components/spectroscopy/plotKinds.test.ts | 15 +++- .../src/components/spectroscopy/plotKinds.ts | 39 ++++++++--- 4 files changed, 109 insertions(+), 27 deletions(-) diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx index fa89d0e8..695aa9bf 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx @@ -7,22 +7,33 @@ import { Typography, } from "@mui/material"; import { DataSource } from "./dataSource"; -import { PLOT_KINDS } from "./plotKinds"; -import type { PlotKind } from "./useSpectroscopyData"; +import { + POSITIONS, + READBACK_ONLY_HELP, + SHAPES, + type Positions, + type Shape, +} from "./plotKinds"; interface Props { onSetDataSource: (event: React.ChangeEvent) => void; dataSource: DataSource; - onSetPlotKind: (kind: PlotKind) => void; - plotKind: PlotKind; + positions: Positions; + onSetPositions: (positions: Positions) => void; + /** The shape for readbacks, kept while the setpoint grid is shown */ + shape: Shape; + onSetShape: (shape: Shape) => void; } export default function Controls({ onSetDataSource, dataSource, - onSetPlotKind, - plotKind, + positions, + onSetPositions, + shape, + onSetShape, }: Props) { + const onGrid = positions === "setpoints"; return ( - Plot Type + Positions onSetPlotKind(event.target.value as PlotKind)} + row + value={positions} + onChange={event => onSetPositions(event.target.value as Positions)} > - {PLOT_KINDS.map(({ kind, label, help }) => ( - + {POSITIONS.map(({ value, label, help }) => ( + } label={label} /> @@ -73,6 +85,39 @@ export default function Controls({ ))} + + + + Plot Type + + onSetShape(event.target.value as Shape)} + > + {SHAPES.map(({ value, label, help }) => { + const disabled = onGrid && value !== "rectangles"; + return ( + + {/* a span, so the tooltip still shows on a disabled option */} + + } + label={label} + disabled={disabled} + /> + + + ); + })} + + ); } diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx index f61f8765..fcff07df 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx @@ -9,7 +9,7 @@ import { SpectroscopyForm } from "./SpectroscopyForm"; import SpectroscopyControls from "./SpectroscopyControls"; import { useInstrumentSession } from "@atlas/app-shell"; import { DataSource } from "./dataSource"; -import type { PlotKind } from "./useSpectroscopyData"; +import { plotKindFor, type Positions, type Shape } from "./plotKinds"; export type SpectroscopyFormData = { total_number_of_scan_points: number; @@ -28,7 +28,8 @@ const PLOT_ASPECT_RATIO = "equal"; function SpectroscopyView() { const [drawerOpen, setDrawerOpen] = useState(true); const [dataSource, setDataSource] = useState(DataSource.Camera); - const [plotKind, setPlotKind] = useState("cells"); + const [positions, setPositions] = useState("setpoints"); + const [shape, setShape] = useState("rectangles"); // ------------------------------------------------------------------------- // Set off workflow when scan ends @@ -73,7 +74,7 @@ function SpectroscopyView() { > @@ -85,8 +86,10 @@ function SpectroscopyView() { , ]} /> diff --git a/apps/visr/src/components/spectroscopy/plotKinds.test.ts b/apps/visr/src/components/spectroscopy/plotKinds.test.ts index 0b706518..117c4b67 100644 --- a/apps/visr/src/components/spectroscopy/plotKinds.test.ts +++ b/apps/visr/src/components/spectroscopy/plotKinds.test.ts @@ -1,4 +1,4 @@ -import { beamWarning } from "./plotKinds"; +import { beamWarning, plotKindFor } from "./plotKinds"; describe("beamWarning", () => { const step = { x: 0.25, y: 0.25, source: "scan step" } as const; @@ -22,3 +22,16 @@ describe("beamWarning", () => { expect(beamWarning("ovals")).toBeNull(); }); }); + +describe("plotKindFor", () => { + it("draws the setpoint grid whatever the shape", () => { + expect(plotKindFor("setpoints", "rectangles")).toBe("cells"); + expect(plotKindFor("setpoints", "ovals")).toBe("cells"); + }); + + it("draws readbacks in the chosen shape", () => { + expect(plotKindFor("readbacks", "rectangles")).toBe("rectangles"); + expect(plotKindFor("readbacks", "gaussian")).toBe("gaussian"); + expect(plotKindFor("readbacks", "ovals")).toBe("ovals"); + }); +}); diff --git a/apps/visr/src/components/spectroscopy/plotKinds.ts b/apps/visr/src/components/spectroscopy/plotKinds.ts index 0483e3c4..3cbeca26 100644 --- a/apps/visr/src/components/spectroscopy/plotKinds.ts +++ b/apps/visr/src/components/spectroscopy/plotKinds.ts @@ -1,29 +1,50 @@ 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 }[] = [ +/** Where the points are drawn: the scan's setpoint grid, or where the motors read back */ +export type Positions = "setpoints" | "readbacks"; + +/** How each point is drawn at its readback position */ +export type Shape = Exclude; + +export const POSITIONS: { value: Positions; label: string; help: string }[] = [ { - 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", + value: "setpoints", + label: "Setpoints", + help: "The grid of the scan's setpoints (from its spec), each cell the mean of the points read back in it", }, { - kind: "rectangles", + value: "readbacks", label: "Readbacks", - help: "A step-sized rectangle at each position the motors actually read back", + help: "Each point at the position the motors actually read back", }, +]; + +export const SHAPES: { value: Shape; label: string; help: string }[] = [ { - kind: "gaussian", + value: "rectangles", + label: "Rectangles", + help: "A step-sized rectangle on each point", + }, + { + value: "gaussian", label: "Gaussian", help: "A beam-sized Gaussian on each point: gaps the beam didn't cover show", }, { - kind: "ovals", + value: "ovals", label: "Footprints", help: "Each point's beam footprint, overlaps split between neighbours", }, ]; +/** Why Gaussian and footprints can't be picked on the setpoint grid */ +export const READBACK_ONLY_HELP = "Drawn at the readback positions only"; + +/** The drawing for a choice of positions and shape */ +export function plotKindFor(positions: Positions, shape: Shape): PlotKind { + return positions === "setpoints" ? "cells" : shape; +} + /** Say so when the beam size isn't the beamline's: the run didn't record one */ export function beamWarning(kind: PlotKind, beam?: Beam): string | null { if ( From d60ddc53b834f307f0937c57a3524012b483bd8a Mon Sep 17 00:00:00 2001 From: Hiran Wijesinghe Date: Mon, 19 Oct 2026 16:20:16 +0100 Subject: [PATCH 6/6] ViSR: draw unmeasured cells as thin lines over the page's background The empty cells' 2-pixel stripes were hard to tell from dark data in the dark theme. They are now the page's background crossed by thin diagonal lines, lighter than a dark background and darker than a light one. For lines that thin, the setpoint grid is drawn at least 240 pixels on its longer side (and at least 4 per cell), with the axes split to match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BsMDFhupHcBFbVJPK8QAvR --- .../spectroscopy/useSpectroscopyData.test.ts | 76 +++++++++++-------- .../spectroscopy/useSpectroscopyData.ts | 66 +++++++++++----- 2 files changed, 89 insertions(+), 53 deletions(-) diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts index 680bf0b0..be7c7d05 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, + cellPixelsFor, + LINE_SPACING, fetchData, orEmptyLike, splitEdges, @@ -18,11 +19,12 @@ function cell( colour: "red" | "green" = "red", ) { const image = toRgbNdt(matrix, colour); + const size = cellPixelsFor(matrix[0].length, matrix.length); const out: number[][] = []; - for (let y = 0; y < CELL_PIXELS; y++) { - for (let x = 0; x < CELL_PIXELS; x++) { - const py = row * CELL_PIXELS + y; - const px = col * CELL_PIXELS + x; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const py = row * size + y; + const px = col * size + x; out.push([0, 1, 2].map(ch => image.get(py, px, ch))); } } @@ -46,12 +48,12 @@ describe("toRgbNdt", () => { 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 colours = new Set(empty.map(p => p.join())); - expect([...colours].sort()).toEqual( - DEFAULT_STRIPES.map(c => c.join()).sort(), - ); + it("draws cells without a value as the background crossed by thin lines", () => { + const empty = cell([[0, null, 10]], 0, 1).map(p => p.join()); + const [background, line] = DEFAULT_STRIPES.map(c => c.join()); + expect(new Set(empty)).toEqual(new Set([background, line])); + const lines = empty.filter(p => p === line).length; + expect(lines / empty.length).toBeCloseTo(1 / LINE_SPACING, 1); expect(fill([[0, null, 10]], 0, 0)).toEqual([0, 0, 0]); // measured dark stays black }); @@ -60,7 +62,7 @@ describe("toRgbNdt", () => { 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", () => { + it("is laid out as rows of height x width x RGB, cellPixelsFor per cell", () => { const image = toRgbNdt( [ [1, 2, 3], @@ -68,30 +70,39 @@ describe("toRgbNdt", () => { ], "green", ); - expect(image.shape).toEqual([2 * CELL_PIXELS, 3 * CELL_PIXELS, 3]); + const size = cellPixelsFor(3, 2); + expect(image.shape).toEqual([2 * size, 3 * size, 3]); }); it("draws a missing channel as the empty placeholder", () => { - expect(toRgbNdt(null, "red").shape).toEqual([ - 3 * CELL_PIXELS, - 3 * CELL_PIXELS, - 3, - ]); + const size = cellPixelsFor(3, 3); + expect(toRgbNdt(null, "red").shape).toEqual([3 * size, 3 * size, 3]); + }); +}); + +describe("cellPixelsFor", () => { + it("draws a small grid at least MIN_IMAGE_PIXELS across", () => { + expect(cellPixelsFor(3, 3)).toBe(80); + expect(cellPixelsFor(17, 5)).toBe(15); + }); + + it("draws a large grid at least CELL_PIXELS per cell", () => { + expect(cellPixelsFor(100, 80)).toBe(4); }); }); describe("stripesOn", () => { - it("is the background and a slightly darker shade on a light page", () => { + it("is the background and darker lines on a light page", () => { expect(stripesOn([246, 246, 249])).toEqual([ [246, 246, 249], - [236, 236, 239], + [198, 198, 201], ]); }); - it("is the background and a slightly lighter shade on a dark page", () => { + it("is the background and lighter lines on a dark page", () => { expect(stripesOn([14, 16, 23])).toEqual([ [14, 16, 23], - [24, 26, 33], + [62, 64, 71], ]); }); @@ -99,8 +110,8 @@ describe("stripesOn", () => { const stripes = stripesOn([14, 16, 23]); const image = toRgbNdt([[null]], "red", stripes); const colours = new Set(); - for (let y = 0; y < CELL_PIXELS; y++) { - for (let x = 0; x < CELL_PIXELS; x++) { + for (let y = 0; y < image.shape[0]; y++) { + for (let x = 0; x < image.shape[1]; x++) { colours.add([0, 1, 2].map(ch => image.get(y, x, ch)).join()); } } @@ -127,13 +138,13 @@ describe("orEmptyLike", () => { }); 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); + it("splits each bin into equal parts, keeping the outer edges", () => { + const edges = splitEdges([0, 4, 12], 4); + expect(edges).toHaveLength(2 * 4 + 1); expect(edges[0]).toBe(0); - expect(edges[CELL_PIXELS]).toBe(4); + expect(edges[4]).toBe(4); expect(edges[edges.length - 1]).toBe(12); - expect(edges[1] - edges[0]).toBeCloseTo(4 / CELL_PIXELS); + expect(edges[1] - edges[0]).toBeCloseTo(1); }); }); @@ -238,9 +249,10 @@ describe("toChannels of /render's answer", () => { expect(pixel(image, 1)).toEqual([255, 255, 255]); }); - it("draws a channel the run lacks, and empty pixels, as stripes", () => { + it("draws a channel the run lacks, and empty pixels, as the empty pattern", () => { const channels = toChannels({ ...answer, outline: undefined }, stripes); - expect(pixel(channels.diodesGreen, 0)).toEqual([...stripes[0]]); - expect(pixel(channels.cameraBlue, 0)).toEqual([...stripes[0]]); + expect(pixel(channels.diodesGreen, 0)).toEqual([...stripes[1]]); // on a line + expect(pixel(channels.cameraBlue, 0)).toEqual([...stripes[1]]); + expect(pixel(channels.diodesGreen, 1)).toEqual([...stripes[0]]); // between lines }); }); diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 7e810157..4277d605 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -75,26 +75,39 @@ export const RENDER_SIZE = 200; 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: faint diagonal stripes, 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. + * Cells no point has landed in yet are drawn as the page's background crossed + * by thin diagonal lines, which can't be mistaken for a measured value (dark + * or not) and run on unbroken across the empty part of the scan. For lines + * that thin, each of /binned's cells is drawn as several pixels square, so the + * whole grid is at least MIN_IMAGE_PIXELS on its longer side (and at least + * CELL_PIXELS per cell); the axes are split to match. */ export const CELL_PIXELS = 4; +export const MIN_IMAGE_PIXELS = 240; + +/** Pixels between the empty cells' lines, along a row */ +export const LINE_SPACING = 6; + +/** Pixels per cell, on each axis, for a grid of width x height cells */ +export function cellPixelsFor(width: number, height: number): number { + return Math.max( + CELL_PIXELS, + Math.ceil(MIN_IMAGE_PIXELS / Math.max(width, height, 1)), + ); +} /** An RGB colour, 0-255 each */ export type Rgb = readonly [number, number, number]; -/** The two colours of the empty cells' stripes */ +/** The empty cells' colours: the background, and its lines */ export type StripeColours = readonly [Rgb, Rgb]; -/** How far the second stripe is from the background, 0-255 per channel */ -export const STRIPE_CONTRAST = 10; +/** How far the lines are from the background, 0-255 per channel */ +export const STRIPE_CONTRAST = 48; /** - * Stripes that barely stand out from the background they sit on: one in the - * background's own colour, the other slightly darker on a light background, - * slightly lighter on a dark one + * The empty cells' colours on a background: the background itself, and thin + * lines a shade darker on a light background, lighter on a dark one */ export function stripesOn(background: Rgb): StripeColours { const luminance = @@ -110,15 +123,15 @@ export function stripesOn(background: Rgb): StripeColours { export const DEFAULT_STRIPES = stripesOn([246, 246, 249]); function emptyColour(row: number, col: number, stripes: StripeColours): Rgb { - return stripes[Math.floor(((row + col) % 4) / 2)]; + return stripes[(row + col) % LINE_SPACING === 0 ? 1 : 0]; } -/** Bin edges with each bin split into CELL_PIXELS equal parts, to match the image */ -export function splitEdges(edges: number[]): number[] { +/** Bin edges with each bin split into `parts` equal parts, to match the image */ +export function splitEdges(edges: number[], parts: number): number[] { const out: number[] = []; for (let i = 0; i < edges.length - 1; i++) { - for (let k = 0; k < CELL_PIXELS; k++) { - out.push(edges[i] + ((edges[i + 1] - edges[i]) * k) / CELL_PIXELS); + for (let k = 0; k < parts; k++) { + out.push(edges[i] + ((edges[i + 1] - edges[i]) * k) / parts); } } out.push(edges[edges.length - 1]); @@ -127,7 +140,7 @@ export function splitEdges(edges: number[]): number[] { /** 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) */ + /** Pixels per matrix cell on each axis (default cellPixelsFor the matrix; 1 for /render's pixels) */ cellPixels?: number; /** How much of each cell is drawn, 0 to 1 (/render): the rest shows the stripes */ coverage?: number[][]; @@ -146,13 +159,14 @@ export function toRgbNdt( matrix: (number | null)[][] | null, colour: RGBColour, stripes: StripeColours = DEFAULT_STRIPES, - { cellPixels = CELL_PIXELS, coverage, outline }: RgbOptions = {}, + { cellPixels, coverage, outline }: RgbOptions = {}, ): NDT { if (!matrix?.length || !matrix[0]?.length) { return emptyNdt(stripes); // skip invalid input } const height = matrix.length; const width = matrix[0].length; + cellPixels ??= cellPixelsFor(width, height); const flat = matrix.flat(); // the range of the values, ignoring empty cells (a loop: /render's images @@ -282,7 +296,10 @@ const FINAL_POLL_ATTEMPTS = 10; type PollResult = "done" | "error" | { retryAfterMs: number }; // initial axes must have min three points... -const initialAxes = binEdgesToAxis(splitEdges([-0.25, 0.25, 0.5, 0.75])); +const INITIAL_EDGES = [-0.25, 0.25, 0.5, 0.75]; +const initialAxes = binEdgesToAxis( + splitEdges(INITIAL_EDGES, cellPixelsFor(3, 3)), +); /** * /binned's or /render's answer as plot images, empty cells in the given @@ -328,8 +345,15 @@ export function toChannels( yValues: initialAxes, }; } + // every channel and both axes split each cell into the same number of pixels + const parts = cellPixelsFor( + binned.RedTotal[0]?.length ?? 1, + binned.RedTotal.length, + ); const image = (matrix: (number | null)[][] | null, colour: RGBColour) => - toRgbNdt(orEmptyLike(matrix, binned.RedTotal), colour, stripes); + toRgbNdt(orEmptyLike(matrix, binned.RedTotal), colour, stripes, { + cellPixels: parts, + }); return { cameraRed: image(binned.RedTotal, "red"), cameraGreen: image(binned.GreenTotal, "green"), @@ -337,8 +361,8 @@ export function toChannels( diodesRed: image(binned.DiodesRed, "red"), diodesGreen: image(binned.DiodesGreen, "green"), diodesBlue: image(binned.DiodesBlue, "blue"), - xValues: binEdgesToAxis(splitEdges(binned.x_limits)), - yValues: binEdgesToAxis(splitEdges(binned.y_limits)), + xValues: binEdgesToAxis(splitEdges(binned.x_limits, parts)), + yValues: binEdgesToAxis(splitEdges(binned.y_limits, parts)), }; }