diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx new file mode 100644 index 00000000..4fd5ee6b --- /dev/null +++ b/apps/visr/src/components/spectroscopy/SpectroscopyControls.tsx @@ -0,0 +1,123 @@ +import { + Box, + FormControlLabel, + Radio, + RadioGroup, + Tooltip, + Typography, +} from "@mui/material"; +import { DataSource } from "./dataSource"; +import { + DATA_SOURCES, + POSITIONS, + READBACK_ONLY_HELP, + SHAPES, + type Positions, + type Shape, +} from "./plotKinds"; + +interface Props { + onSetDataSource: (event: React.ChangeEvent) => void; + dataSource: DataSource; + positions: Positions; + onSetPositions: (positions: Positions) => void; + /** The shape for readbacks, kept while the setpoint grid is shown */ + shape: Shape; + onSetShape: (shape: Shape) => void; +} + +export default function Controls({ + onSetDataSource, + dataSource, + positions, + onSetPositions, + shape, + onSetShape, +}: Props) { + const onGrid = positions === "setpoints"; + return ( + + + Plots + + + + + Data Source + + + {DATA_SOURCES.map(({ value, label, help }) => ( + + } + label={label} + /> + + ))} + + + + + + Positions + + onSetPositions(event.target.value as Positions)} + > + {POSITIONS.map(({ value, label, help }) => ( + + } + label={label} + /> + + ))} + + + + + + Plot Type + + onSetShape(event.target.value as Shape)} + > + {SHAPES.map(({ value, label, help }) => { + const disabled = onGrid && value !== "rectangles"; + return ( + + {/* a span, so the tooltip still shows on a disabled option */} + + } + label={label} + disabled={disabled} + /> + + + ); + })} + + + + ); +} diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index bdc2b66e..0bdb6091 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -1,8 +1,35 @@ -import { Box, 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 { useLayoutEffect, useState, type ComponentProps } from "react"; -import { useSpectroscopyData } from "./useSpectroscopyData"; +import { + useEffect, + useLayoutEffect, + useMemo, + useState, + type ComponentProps, +} from "react"; +import { + DEFAULT_STRIPES, + stripesOn, + useSpectroscopyData, + type DataChannels, + type PlotKind, + type Rgb, + type StripeColours, +} from "./useSpectroscopyData"; +import { beamWarning } from "./plotKinds"; +import { DataSource } from "./dataSource"; +import { + SCAN_STATUS_BUSY, + SCAN_STATUS_TEXT, + type ScanStatus, +} from "./scanStatus"; const CHANNELS = [ { key: "red", label: "Red channel" }, @@ -14,21 +41,178 @@ type ChannelKey = (typeof CHANNELS)[number]["key"]; type PlotValues = ComponentProps["values"]; export type SpectroscopyData = Partial>; +// --------------------------------------------------------------------------- +// Data source -> channel mapping +// --------------------------------------------------------------------------- + +const SOURCE_CHANNELS: Record< + DataSource, + Record +> = { + [DataSource.Camera]: { + red: "cameraRed", + green: "cameraGreen", + blue: "cameraBlue", + }, + [DataSource.Diodes]: { + red: "diodesRed", + green: "diodesGreen", + blue: "diodesBlue", + }, +}; + +function selectChannels( + data: DataChannels | null | undefined, + dataSource: DataSource, +): SpectroscopyData { + if (!data) return {}; + const mapping = SOURCE_CHANNELS[dataSource]; + return { + red: data[mapping.red], + green: data[mapping.green], + blue: data[mapping.blue], + }; +} + +// --------------------------------------------------------------------------- +// 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, + note, +}: { + status: ScanStatus | null; + /** A remark about the plots themselves, on the left */ + note?: string | null; +}) { + return ( + + {note && ( + + {note} + + )} + {status && } + + ); +} + +// --------------------------------------------------------------------------- +// Empty-cell stripes in the colours of the page behind the plots +// --------------------------------------------------------------------------- + +/** The first opaque background colour at or above `element`, if any */ +function backgroundBehind(element: HTMLElement | null): Rgb | null { + for (let el = element; el; el = el.parentElement) { + const parts = getComputedStyle(el).backgroundColor.match(/[\d.]+/g); + if (parts && parts.length >= 3 && (parts.length < 4 || +parts[3] > 0)) { + return [+parts[0], +parts[1], +parts[2]]; + } + } + return null; +} + +/** + * Stripes matching the background behind `element`, read again when the + * colour scheme changes (after the browser has applied it) + */ +function useStripesBehind( + element: React.RefObject, +): StripeColours { + const { mode, systemMode } = useColorScheme(); + const scheme = mode === "system" ? systemMode : mode; + const [background, setBackground] = useState(null); + useEffect(() => { + const frame = requestAnimationFrame(() => { + const found = backgroundBehind(element.current); + if (found) setBackground(found.join(",")); + }); + return () => cancelAnimationFrame(frame); + }, [element, scheme]); + return useMemo( + () => + background + ? stripesOn(background.split(",").map(Number) as unknown as Rgb) + : DEFAULT_STRIPES, + [background], + ); +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- interface SpectroscopyPlotsProps { + dataSource: DataSource; + plotKind: PlotKind; expanded: boolean; plotAspectRatio: number | "auto" | "equal"; } function SpectroscopyPlots({ + dataSource, + plotKind, expanded, plotAspectRatio, }: SpectroscopyPlotsProps) { - const { data: channels } = useSpectroscopyData(); const { width, containerRef, mounted } = useContainerWidth(); + const stripes = useStripesBehind( + containerRef as React.RefObject, + ); + const { data, status } = useSpectroscopyData(plotKind, stripes); + const channels = useMemo( + () => selectChannels(data, dataSource), + [data, dataSource], + ); // ------------------------------------------------------------------------- // Grid sizing @@ -80,15 +264,19 @@ function SpectroscopyPlots({ // ------------------------------------------------------------------------- // Views // ------------------------------------------------------------------------- - const views = CHANNELS.map(({ key }) => ( - - )); + const views = CHANNELS.map(({ key }) => { + const values = channels[key]; + if (!values) return null; + return ( + + ); + }); const titles = CHANNELS.map(({ label }) => label); @@ -100,7 +288,6 @@ function SpectroscopyPlots({ key={i} sx={{ width: "100%", - // height: "100%", minHeight: 0, display: "flex", flexDirection: "column", @@ -140,32 +327,44 @@ function SpectroscopyPlots({ // ------------------------------------------------------------------------- return ( } sx={{ flex: "1 1 0", + display: "flex", + flexDirection: "column", width: "100%", minWidth: 0, - height: gridHeight, - minHeight: minimumGridHeight, - overflow: "visible", + minHeight: 0, }} > - {mounted && ( - {}} - > - {plots} - - )} + } + sx={{ + flex: "1 1 0", + width: "100%", + minWidth: 0, + height: gridHeight, + minHeight: minimumGridHeight, + overflow: "visible", + }} + > + {mounted && ( + {}} + > + {plots} + + )} + + ); } diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx index 28efb596..144355f8 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx @@ -6,7 +6,11 @@ import { useSubmitWorkflow } from "../../hooks/useSubmitWorkflow"; import { visitTextToVisit } from "../../utils/common"; import ControlsDrawer from "../ControlsDrawer"; import { SpectroscopyForm } from "./SpectroscopyForm"; +import SpectroscopyControls from "./SpectroscopyControls"; import { useInstrumentSession } from "@atlas/app-shell"; +import { DataSource } from "./dataSource"; +import { plotKindFor, type Positions, type Shape } from "./plotKinds"; +import { useSharedState } from "../../hooks/useSharedState"; export type SpectroscopyFormData = { total_number_of_scan_points: number; @@ -25,6 +29,20 @@ const PLOT_ASPECT_RATIO = "equal"; function SpectroscopyView() { const [drawerOpen, setDrawerOpen] = useState(true); + // Shared across all open tabs (and restored on reload) + const [dataSource, setDataSource] = useSharedState( + "spectroscopy:dataSource", + DataSource.Camera, + ); + const [positions, setPositions] = useSharedState( + "spectroscopy:positions", + "setpoints", + ); + const [shape, setShape] = useSharedState( + "spectroscopy:shape", + "rectangles", + ); + // ------------------------------------------------------------------------- // Set off workflow when scan ends // ------------------------------------------------------------------------- @@ -47,6 +65,9 @@ function SpectroscopyView() { } }, [scanEvent, instrumentSession, submitWorkflow]); + const handleSetDataSource = (event: React.ChangeEvent) => + setDataSource(event.target.value as DataSource); + // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- @@ -64,13 +85,26 @@ function SpectroscopyView() { }} > setDrawerOpen(prev => !prev)} - controls={} + controls={[ + , + , + ]} /> ); diff --git a/apps/visr/src/components/spectroscopy/dataSource.ts b/apps/visr/src/components/spectroscopy/dataSource.ts new file mode 100644 index 00000000..fda01b0f --- /dev/null +++ b/apps/visr/src/components/spectroscopy/dataSource.ts @@ -0,0 +1,4 @@ +export enum DataSource { + Camera = "camera", + Diodes = "diodes", +} diff --git a/apps/visr/src/components/spectroscopy/plotKinds.test.ts b/apps/visr/src/components/spectroscopy/plotKinds.test.ts new file mode 100644 index 00000000..315ffb29 --- /dev/null +++ b/apps/visr/src/components/spectroscopy/plotKinds.test.ts @@ -0,0 +1,37 @@ +import { beamWarning, plotKindFor } from "./plotKinds"; + +describe("beamWarning", () => { + const step = { x: 0.25, y: 0.25, source: "scan step" } as const; + + it("warns when the beam is only the scan step, for the beam-sized drawings", () => { + expect(beamWarning("ovals", step)).toContain("0.25 × 0.25"); + expect(beamWarning("gaussian", step)).toContain( + "not the real beam footprint", + ); + }); + + it("says nothing when the run records its beam size", () => { + expect( + beamWarning("ovals", { x: 0.1, y: 0.2, source: "metadata" }), + ).toBeNull(); + }); + + it("says nothing for drawings that don't use the beam", () => { + expect(beamWarning("cells", step)).toBeNull(); + expect(beamWarning("rectangles", step)).toBeNull(); + expect(beamWarning("ovals")).toBeNull(); + }); +}); + +describe("plotKindFor", () => { + it("draws the setpoint grid whatever the shape", () => { + expect(plotKindFor("setpoints", "rectangles")).toBe("cells"); + expect(plotKindFor("setpoints", "ovals")).toBe("cells"); + }); + + it("draws readbacks in the chosen shape", () => { + expect(plotKindFor("readbacks", "rectangles")).toBe("rectangles"); + expect(plotKindFor("readbacks", "gaussian")).toBe("gaussian"); + expect(plotKindFor("readbacks", "ovals")).toBe("ovals"); + }); +}); diff --git a/apps/visr/src/components/spectroscopy/plotKinds.ts b/apps/visr/src/components/spectroscopy/plotKinds.ts new file mode 100644 index 00000000..7141f61a --- /dev/null +++ b/apps/visr/src/components/spectroscopy/plotKinds.ts @@ -0,0 +1,79 @@ +import { DataSource } from "./dataSource"; +import type { Beam, PlotKind } from "./useSpectroscopyData"; + +/** Where the points are drawn: the scan's setpoint grid, or where the motors read back */ +export type Positions = "setpoints" | "readbacks"; + +/** How each point is drawn at its readback position */ +export type Shape = Exclude; + +export const DATA_SOURCES: { + value: DataSource; + label: string; + help: string; +}[] = [ + { + value: DataSource.Camera, + label: "Camera", + help: "Data taken from regions of interest on the camera image", + }, + { + value: DataSource.Diodes, + label: "Diodes", + help: "Data taken from diodes placed along the diffracted spectrum", + }, +]; + +export const POSITIONS: { value: Positions; label: string; help: string }[] = [ + { + value: "setpoints", + label: "Setpoints", + help: "Each point at the position the scan planned to collect data at. Where several points fall in the same grid square, they are averaged", + }, + { + value: "readbacks", + label: "Readbacks", + help: "Each point at the position the motors collected data at", + }, +]; + +export const SHAPES: { value: Shape; label: string; help: string }[] = [ + { + value: "rectangles", + label: "Rectangles", + help: "A rectangle which is step-size by step-size. For 'Setpoints' positions this is the raw data at each point, for 'Readbacks' positions this is the interpolated data at each point", + }, + { + value: "gaussian", + label: "Gaussian", + help: "A representation of the interpolated data. For 'Readbacks' positions this is the interpolated data (interpolated using a gaussian footprint) at each point", + }, + { + value: "ovals", + label: "Footprints", + help: "A representation of the interpolated data. For 'Readbacks' positions this is the interpolated data (interpolated using the footprint of the beam) at each point", + }, +]; + +/** Why Gaussian and footprints can't be picked on the setpoint grid */ +export const READBACK_ONLY_HELP = "Drawn at the readback positions only"; + +/** The drawing for a choice of positions and shape */ +export function plotKindFor(positions: Positions, shape: Shape): PlotKind { + return positions === "setpoints" ? "cells" : shape; +} + +/** Say so when the beam size isn't the beamline's: the run didn't record one */ +export function beamWarning(kind: PlotKind, beam?: Beam): string | null { + if ( + (kind !== "gaussian" && kind !== "ovals") || + beam?.source !== "scan step" + ) { + return null; + } + const size = (v: number) => Number(v.toPrecision(3)); + return ( + `This run doesn't record its beam size, so this is drawn with a beam of ` + + `one scan step (${size(beam.x)} × ${size(beam.y)}), not the real beam footprint.` + ); +} 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.test.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts new file mode 100644 index 00000000..be7c7d05 --- /dev/null +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.test.ts @@ -0,0 +1,258 @@ +import { + binEdgesToAxis, + cellPixelsFor, + LINE_SPACING, + fetchData, + orEmptyLike, + splitEdges, + DEFAULT_STRIPES, + stripesOn, + toChannels, + toRgbNdt, +} from "./useSpectroscopyData"; + +/** Every pixel of one cell as [r, g, b] */ +function cell( + matrix: (number | null)[][], + row: number, + col: number, + colour: "red" | "green" = "red", +) { + const image = toRgbNdt(matrix, colour); + const size = cellPixelsFor(matrix[0].length, matrix.length); + const out: number[][] = []; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const py = row * size + y; + const px = col * size + x; + out.push([0, 1, 2].map(ch => image.get(py, px, ch))); + } + } + return out; +} + +/** The single colour a measured cell is filled with */ +function fill(matrix: (number | null)[][], row: number, col: number) { + const pixels = cell(matrix, row, col); + expect(new Set(pixels.map(p => p.join()))).toHaveLength(1); + return pixels[0]; +} + +describe("toRgbNdt", () => { + it("scales each value over the matrix's range in its own colour", () => { + expect([0, 1, 2].map(c => fill([[10, 15, 20]], 0, c))).toEqual([ + [0, 0, 0], + [128, 0, 0], + [255, 0, 0], + ]); + expect(cell([[0, 4]], 0, 1, "green")[0]).toEqual([0, 255, 0]); + }); + + it("draws cells without a value as the background crossed by thin lines", () => { + const empty = cell([[0, null, 10]], 0, 1).map(p => p.join()); + const [background, line] = DEFAULT_STRIPES.map(c => c.join()); + expect(new Set(empty)).toEqual(new Set([background, line])); + const lines = empty.filter(p => p === line).length; + expect(lines / empty.length).toBeCloseTo(1 / LINE_SPACING, 1); + expect(fill([[0, null, 10]], 0, 0)).toEqual([0, 0, 0]); // measured dark stays black + }); + + it("draws a single point, or equal values, at full brightness", () => { + expect(fill([[42]], 0, 0)).toEqual([255, 0, 0]); + expect(fill([[7, null, 7]], 0, 2)).toEqual([255, 0, 0]); + }); + + it("is laid out as rows of height x width x RGB, cellPixelsFor per cell", () => { + const image = toRgbNdt( + [ + [1, 2, 3], + [4, 5, 6], + ], + "green", + ); + const size = cellPixelsFor(3, 2); + expect(image.shape).toEqual([2 * size, 3 * size, 3]); + }); + + it("draws a missing channel as the empty placeholder", () => { + const size = cellPixelsFor(3, 3); + expect(toRgbNdt(null, "red").shape).toEqual([3 * size, 3 * size, 3]); + }); +}); + +describe("cellPixelsFor", () => { + it("draws a small grid at least MIN_IMAGE_PIXELS across", () => { + expect(cellPixelsFor(3, 3)).toBe(80); + expect(cellPixelsFor(17, 5)).toBe(15); + }); + + it("draws a large grid at least CELL_PIXELS per cell", () => { + expect(cellPixelsFor(100, 80)).toBe(4); + }); +}); + +describe("stripesOn", () => { + it("is the background and darker lines on a light page", () => { + expect(stripesOn([246, 246, 249])).toEqual([ + [246, 246, 249], + [198, 198, 201], + ]); + }); + + it("is the background and lighter lines on a dark page", () => { + expect(stripesOn([14, 16, 23])).toEqual([ + [14, 16, 23], + [62, 64, 71], + ]); + }); + + it("colours toRgbNdt's empty cells", () => { + const stripes = stripesOn([14, 16, 23]); + const image = toRgbNdt([[null]], "red", stripes); + const colours = new Set(); + for (let y = 0; y < image.shape[0]; y++) { + for (let x = 0; x < image.shape[1]; x++) { + colours.add([0, 1, 2].map(ch => image.get(y, x, ch)).join()); + } + } + expect([...colours].sort()).toEqual(stripes.map(c => c.join()).sort()); + }); +}); + +describe("orEmptyLike", () => { + it("keeps a channel that is there", () => { + expect(orEmptyLike([[1, 2]], [[3, 4]])).toEqual([[1, 2]]); + }); + + it("gives a missing channel empty cells the shape of another", () => { + expect( + orEmptyLike(null, [ + [1, null], + [3, 4], + ]), + ).toEqual([ + [null, null], + [null, null], + ]); + }); +}); + +describe("splitEdges", () => { + it("splits each bin into equal parts, keeping the outer edges", () => { + const edges = splitEdges([0, 4, 12], 4); + expect(edges).toHaveLength(2 * 4 + 1); + expect(edges[0]).toBe(0); + expect(edges[4]).toBe(4); + expect(edges[edges.length - 1]).toBe(12); + expect(edges[1] - edges[0]).toBeCloseTo(1); + }); +}); + +describe("binEdgesToAxis", () => { + it("gives the plot the edges themselves, one more than the cells", () => { + const axis = binEdgesToAxis([-0.5, 0.5, 1.5]); + expect(Array.from(axis.data as Float64Array)).toEqual([-0.5, 0.5, 1.5]); + expect(axis.shape).toEqual([3]); + }); + + it("needs at least two edges", () => { + expect(() => binEdgesToAxis([1])).toThrow("At least two bin edges"); + }); +}); + +describe("fetchData", () => { + afterEach(() => vi.unstubAllGlobals()); + + function respond( + status: number, + headers: Record = {}, + body = {}, + ) { + vi.stubGlobal( + "fetch", + vi.fn( + async () => new Response(JSON.stringify(body), { status, headers }), + ), + ); + } + + it("returns the binned images", async () => { + respond(200, {}, { RedTotal: [[1]] }); + expect(await fetchData("uid")).toEqual({ RedTotal: [[1]] }); + }); + + it("reports a 503 as not ready yet, waiting as long as Retry-After asks", async () => { + respond(503, { "Retry-After": "2" }); + expect(await fetchData("uid")).toEqual({ + notReady: true, + retryAfterMs: 2000, + }); + }); + + it("waits a second after a 503 without Retry-After", async () => { + respond(503); + expect(await fetchData("uid")).toEqual({ + notReady: true, + retryAfterMs: 1000, + }); + }); + + it("still throws for other errors", async () => { + respond(500); + await expect(fetchData("uid")).rejects.toThrow(); + }); +}); + +describe("toChannels of /render's answer", () => { + const stripes = stripesOn([246, 246, 249]); + const answer = { + viewport: { x0: 0, x1: 2, y0: 10, y1: 11, width: 2, height: 1 }, + channels: { + RedTotal: [[0, 4]], + GreenTotal: [[1, 1]], + BlueTotal: [[null, 2]], + DiodesRed: [[3, null]], + DiodesGreen: null, + DiodesBlue: null, + }, + coverage: [[1, 0.5]], + outline: [[0, 1]], + beam: { x: 1, y: 1, source: "scan step" as const }, + }; + const pixel = (image: ReturnType, x: number) => + [0, 1, 2].map(ch => image.get(0, x, ch)); + + it("draws one pixel per value, over the viewport's pixel edges", () => { + const channels = toChannels(answer, stripes); + expect(channels.cameraRed.shape).toEqual([1, 2, 3]); + expect(Array.from(channels.xValues.data as Float64Array)).toEqual([ + 0, 1, 2, + ]); + expect(Array.from(channels.yValues.data as Float64Array)).toEqual([10, 11]); + expect(channels.beam).toEqual(answer.beam); + }); + + it("fades partly covered pixels into the stripes", () => { + const channels = toChannels({ ...answer, outline: undefined }, stripes); + expect(pixel(channels.cameraRed, 0)).toEqual([0, 0, 0]); // fully covered + const [r, g] = pixel(channels.cameraRed, 1); // half 255, half its stripe + expect(r).toBe(Math.round(0.5 * 255 + 0.5 * stripes[0][0])); + expect(g).toBe(Math.round(0.5 * stripes[0][1])); + }); + + it("draws outlines in white over the pixels, if asked", () => { + const image = toRgbNdt([[0, 4]], "red", stripes, { + cellPixels: 1, + outline: answer.outline, + }); + expect(pixel(image, 0)).toEqual([0, 0, 0]); + expect(pixel(image, 1)).toEqual([255, 255, 255]); + }); + + it("draws a channel the run lacks, and empty pixels, as the empty pattern", () => { + const channels = toChannels({ ...answer, outline: undefined }, stripes); + expect(pixel(channels.diodesGreen, 0)).toEqual([...stripes[1]]); // on a line + expect(pixel(channels.cameraBlue, 0)).toEqual([...stripes[1]]); + expect(pixel(channels.diodesGreen, 1)).toEqual([...stripes[0]]); // between lines + }); +}); diff --git a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts index 6efefc25..4277d605 100644 --- a/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts +++ b/apps/visr/src/components/spectroscopy/useSpectroscopyData.ts @@ -1,117 +1,392 @@ -import { type NDT } from "@diamondlightsource/davidia"; +import type { NDT } from "@diamondlightsource/davidia"; import ndarray from "ndarray"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useScanEvents } from "../../hooks/scanEvents/useScanEvents"; +import type { ScanStatus } from "./scanStatus"; /** SpectroscopyData wrapped as NDT for ease of Davidia plotting */ export interface DataChannels { - red: NDT; - green: NDT; - blue: NDT; + cameraRed: NDT; + cameraGreen: NDT; + cameraBlue: NDT; + diodesRed: NDT; + diodesGreen: NDT; + diodesBlue: NDT; xValues: NDT; yValues: NDT; + /** The beam size /render drew with, and where it came from (Gaussian and footprints) */ + beam?: Beam; } +/** A beam size (FWHM, in motor units) and where /render took it from */ +export type Beam = { + x: number; + y: number; + source: "request" | "metadata" | "scan step"; +}; + /** Return type of `/api/data/binned` */ type SpectroscopyData = { - RedTotal: number[][]; - GreenTotal: number[][]; - BlueTotal: number[][]; + RedTotal: (number | null)[][]; + GreenTotal: (number | null)[][]; + BlueTotal: (number | null)[][]; + /** Null for a run without diodes */ + DiodesRed: (number | null)[][] | null; + DiodesGreen: (number | null)[][] | null; + DiodesBlue: (number | null)[][] | null; /** X bin edges with size of the above datasets + 1 */ x_limits: number[]; /** Y bin edges with size of the above datasets + 1 */ y_limits: number[]; }; -type RGBColour = "red" | "green" | "blue" | "gray"; +type Matrix = (number | null)[][]; + +/** Return type of `/api/data/render` (JSON) */ +type RenderData = { + viewport: { + x0: number; + x1: number; + y0: number; + y1: number; + width: number; + height: number; + }; + channels: Record<"RedTotal" | "GreenTotal" | "BlueTotal", Matrix> & + /** Null for a run without diodes */ + Record<"DiodesRed" | "DiodesGreen" | "DiodesBlue", Matrix | null>; + coverage: number[][]; + outline?: number[][]; + beam?: Beam; +}; + +/** How the scan is drawn: /binned's cells, or one of /render's kinds */ +export type PlotKind = "cells" | "rectangles" | "gaussian" | "ovals"; + +/** + * Whether footprints are drawn with /render's white outlines; without them + * the footprints meet edge to edge + */ +export const DRAW_OUTLINES = false; + +/** Longest side, in pixels, of the images /render draws */ +export const RENDER_SIZE = 200; + +export type RGBColour = "red" | "green" | "blue" | "gray"; -function toRgbNdt(matrix: (number | null)[][], colour: RGBColour): NDT { +/** + * Cells no point has landed in yet are drawn as the page's background crossed + * by thin diagonal lines, which can't be mistaken for a measured value (dark + * or not) and run on unbroken across the empty part of the scan. For lines + * that thin, each of /binned's cells is drawn as several pixels square, so the + * whole grid is at least MIN_IMAGE_PIXELS on its longer side (and at least + * CELL_PIXELS per cell); the axes are split to match. + */ +export const CELL_PIXELS = 4; +export const MIN_IMAGE_PIXELS = 240; + +/** Pixels between the empty cells' lines, along a row */ +export const LINE_SPACING = 6; + +/** Pixels per cell, on each axis, for a grid of width x height cells */ +export function cellPixelsFor(width: number, height: number): number { + return Math.max( + CELL_PIXELS, + Math.ceil(MIN_IMAGE_PIXELS / Math.max(width, height, 1)), + ); +} + +/** An RGB colour, 0-255 each */ +export type Rgb = readonly [number, number, number]; + +/** The empty cells' colours: the background, and its lines */ +export type StripeColours = readonly [Rgb, Rgb]; + +/** How far the lines are from the background, 0-255 per channel */ +export const STRIPE_CONTRAST = 48; + +/** + * The empty cells' colours on a background: the background itself, and thin + * lines a shade darker on a light background, lighter on a dark one + */ +export function stripesOn(background: Rgb): StripeColours { + const luminance = + 0.299 * background[0] + 0.587 * background[1] + 0.114 * background[2]; + const shift = luminance > 127 ? -STRIPE_CONTRAST : STRIPE_CONTRAST; + const shifted = background.map(c => + Math.min(255, Math.max(0, c + shift)), + ) as unknown as Rgb; + return [background, shifted]; +} + +/** The Diamond theme's light background, until the page's own is known */ +export const DEFAULT_STRIPES = stripesOn([246, 246, 249]); + +function emptyColour(row: number, col: number, stripes: StripeColours): Rgb { + return stripes[(row + col) % LINE_SPACING === 0 ? 1 : 0]; +} + +/** Bin edges with each bin split into `parts` equal parts, to match the image */ +export function splitEdges(edges: number[], parts: number): number[] { + const out: number[] = []; + for (let i = 0; i < edges.length - 1; i++) { + for (let k = 0; k < parts; k++) { + out.push(edges[i] + ((edges[i + 1] - edges[i]) * k) / parts); + } + } + out.push(edges[edges.length - 1]); + return out; +} + +/** How to draw a matrix as an image */ +export type RgbOptions = { + /** Pixels per matrix cell on each axis (default cellPixelsFor the matrix; 1 for /render's pixels) */ + cellPixels?: number; + /** How much of each cell is drawn, 0 to 1 (/render): the rest shows the stripes */ + coverage?: number[][]; + /** Opacity of white outlines over each cell, 0 to 1 (/render's footprints) */ + outline?: number[][]; +}; + +/** + * The matrix as an RGB image in one colour, scaled over its own range, each + * cell `cellPixels` pixels square. Cells without a value are stripes in the + * given colours, and a partly covered cell fades into them. If every value is + * the same (a single point, say), they are drawn at full brightness rather + * than black. + */ +export function toRgbNdt( + matrix: (number | null)[][] | null, + colour: RGBColour, + stripes: StripeColours = DEFAULT_STRIPES, + { cellPixels, coverage, outline }: RgbOptions = {}, +): NDT { if (!matrix?.length || !matrix[0]?.length) { - return EMPTY_NDT; // skip invalid input + return emptyNdt(stripes); // skip invalid input } const height = matrix.length; const width = matrix[0].length; - - // Flatten and filter out nulls for normalisation + cellPixels ??= cellPixelsFor(width, height); const flat = matrix.flat(); - const valid = flat.filter((v): v is number => v !== null && !isNaN(v)); - // Avoid crashes when no valid values - const min = valid.length ? Math.min(...valid) : 0; - const max = valid.length ? Math.max(...valid) : 1; - const scale = max > min ? 255 / (max - min) : 1; + // the range of the values, ignoring empty cells (a loop: /render's images + // are too large to spread into Math.min) + let min = Infinity; + let max = -Infinity; + for (const v of flat) { + if (v !== null && !isNaN(v)) { + min = Math.min(min, v); + max = Math.max(max, v); + } + } + const scale = max > min ? 255 / (max - min) : 0; + const lit = { red: [0], green: [1], blue: [2], gray: [0, 1, 2] }[colour]; - const rgb = new Uint8Array(width * height * 3); + const W = width * cellPixels; + const H = height * cellPixels; + const rgb = new Uint8Array(W * H * 3); - for (let i = 0; i < flat.length; i++) { - const v = flat[i]; - let scaled = 0; - if (v !== null && !isNaN(v)) { - scaled = Math.round((v - min) * scale); - } // else stays 0 (black) - - switch (colour) { - case "red": - rgb[i * 3] = scaled; - break; - case "green": - rgb[i * 3 + 1] = scaled; - break; - case "blue": - rgb[i * 3 + 2] = scaled; - break; - case "gray": - rgb[i * 3] = scaled; - rgb[i * 3 + 1] = scaled; - rgb[i * 3 + 2] = scaled; - break; + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const i = (y * W + x) * 3; + const r = Math.floor(y / cellPixels); + const c = Math.floor(x / cellPixels); + const v = flat[r * width + c]; + const empty = emptyColour(y, x, stripes); + const covered = v === null || isNaN(v) ? 0 : (coverage?.[r]?.[c] ?? 1); + const scaled = covered && max > min ? (v! - min) * scale : 255; + const line = outline?.[r]?.[c] ?? 0; + for (let k = 0; k < 3; k++) { + const value = lit.includes(k) ? scaled : 0; + const out = covered * value + (1 - covered) * empty[k]; + rgb[i + k] = Math.round(out * (1 - line) + 255 * line); + } } } - return ndarray(rgb, [height, width, 3]) as NDT; + return ndarray(rgb, [H, W, 3]) as NDT; +} + +/** Placeholder before any data: 3x3 empty cells, matching initialAxes */ +function emptyNdt(stripes: StripeColours): NDT { + return toRgbNdt( + [ + [null, null, null], + [null, null, null], + [null, null, null], + ], + "gray", + stripes, + ); +} + +/** + * A channel's matrix, or for a run without it (null), empty cells the shape of + * the camera's, so it still lines up with the axes + */ +export function orEmptyLike( + matrix: (number | null)[][] | null, + like: (number | null)[][], +): (number | null)[][] { + return matrix ?? like.map(row => row.map(() => null)); } -/** Placeholder empty gray dataset */ -const EMPTY_NDT = toRgbNdt([[0, 0, 0]], "gray"); -/** given array of edges size L, returns the centre points, size L-1, as NDT */ -export function binEdgesToCentrePoints(edges: number[]): NDT { +/** + * Bin edges as plot axis values. The plot reads one more value than there are + * pixels as the pixels' edges, so each cell sits exactly between its own edges. + * (Given cell centres instead, it reads them as each pixel's leading edge and + * shifts the image by half a cell.) + */ +export function binEdgesToAxis(edges: number[]): NDT { if (edges.length < 2) { throw new Error("At least two bin edges are required"); } + return ndarray(Float64Array.from(edges), [edges.length]) as NDT; +} - const centres = new Float64Array(edges.length - 1); +/** /binned or /render answered 503: the data file is behind the catalog, try again shortly */ +export type NotReady = { notReady: true; retryAfterMs: number }; - for (let i = 0; i < centres.length; i++) { - centres[i] = (edges[i] + edges[i + 1]) / 2; +/** + * The JSON at `url`, or NotReady when the server says the data isn't written + * yet (503, waiting as long as its Retry-After asks, 1 s by default). Any + * other failure throws. + */ +async function fetchJson(url: string): Promise { + const resp = await fetch(url); + if (resp.status === 503) { + const seconds = Number(resp.headers.get("Retry-After")); + return { + notReady: true, + retryAfterMs: + (Number.isFinite(seconds) && seconds > 0 ? seconds : 1) * 1000, + }; } + if (!resp.ok) throw new Error(resp.statusText); + return await resp.json(); // here we should use zod +} - return ndarray(centres, [centres.length]); +/** The binned images of a run, or NotReady */ +export function fetchData(uuid: string): Promise { + return fetchJson(`/api/data/binned/${uuid}`); } -async function fetchData(uuid: string): Promise { - const url = `/api/data/binned/${uuid}`; - const resp = await fetch(url); - if (!resp.ok) throw new Error(resp.statusText); - return await resp.json(); // here we should use zod +/** A run drawn by /render over the scan's extent, or NotReady */ +export function fetchRender( + uuid: string, + kind: Exclude, +): Promise { + return fetchJson( + `/api/data/render/${uuid}?kind=${kind}&size=${RENDER_SIZE}`, + ); +} + +/** Evenly spaced edges from `start` to `stop` for `count` pixels */ +function pixelEdges(start: number, stop: number, count: number): number[] { + return Array.from( + { length: count + 1 }, + (_, i) => start + ((stop - start) * i) / count, + ); } +/** Tries for the final image of a scan, when a 503 isn't followed by another poll */ +const FINAL_POLL_ATTEMPTS = 10; + +/** What one poll got: the data, a failure, or the wait the server asked for */ +type PollResult = "done" | "error" | { retryAfterMs: number }; + // initial axes must have min three points... -const initialAxes = binEdgesToCentrePoints([-0.25, 0.25, 0.5, 0.75]); +const INITIAL_EDGES = [-0.25, 0.25, 0.5, 0.75]; +const initialAxes = binEdgesToAxis( + splitEdges(INITIAL_EDGES, cellPixelsFor(3, 3)), +); -export function useSpectroscopyData(): { +/** + * /binned's or /render's answer as plot images, empty cells in the given + * stripes. A channel the run lacks (the diodes) is drawn all empty. + */ +export function toChannels( + answer: SpectroscopyData | RenderData | null, + stripes: StripeColours, +): DataChannels { + if (answer && "viewport" in answer) { + const v = answer.viewport; + const ch = answer.channels; + const options = { + cellPixels: 1, + coverage: answer.coverage, + outline: DRAW_OUTLINES ? answer.outline : undefined, + }; + const image = (matrix: Matrix | null, colour: RGBColour) => + toRgbNdt(orEmptyLike(matrix, ch.RedTotal), colour, stripes, options); + return { + cameraRed: image(ch.RedTotal, "red"), + cameraGreen: image(ch.GreenTotal, "green"), + cameraBlue: image(ch.BlueTotal, "blue"), + diodesRed: image(ch.DiodesRed, "red"), + diodesGreen: image(ch.DiodesGreen, "green"), + diodesBlue: image(ch.DiodesBlue, "blue"), + xValues: binEdgesToAxis(pixelEdges(v.x0, v.x1, v.width)), + yValues: binEdgesToAxis(pixelEdges(v.y0, v.y1, v.height)), + beam: answer.beam, + }; + } + const binned = answer; + if (!binned) { + const empty = emptyNdt(stripes); + return { + cameraRed: empty, + cameraGreen: empty, + cameraBlue: empty, + diodesRed: empty, + diodesGreen: empty, + diodesBlue: empty, + xValues: initialAxes, + yValues: initialAxes, + }; + } + // every channel and both axes split each cell into the same number of pixels + const parts = cellPixelsFor( + binned.RedTotal[0]?.length ?? 1, + binned.RedTotal.length, + ); + const image = (matrix: (number | null)[][] | null, colour: RGBColour) => + toRgbNdt(orEmptyLike(matrix, binned.RedTotal), colour, stripes, { + cellPixels: parts, + }); + return { + cameraRed: image(binned.RedTotal, "red"), + cameraGreen: image(binned.GreenTotal, "green"), + cameraBlue: image(binned.BlueTotal, "blue"), + diodesRed: image(binned.DiodesRed, "red"), + diodesGreen: image(binned.DiodesGreen, "green"), + diodesBlue: image(binned.DiodesBlue, "blue"), + xValues: binEdgesToAxis(splitEdges(binned.x_limits, parts)), + yValues: binEdgesToAxis(splitEdges(binned.y_limits, parts)), + }; +} + +/** + * The scan drawn as `kind`, polled while it runs, with empty cells in + * `stripes` + */ +export function useSpectroscopyData( + kind: PlotKind = "cells", + stripes: StripeColours = DEFAULT_STRIPES, +): { 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({ - red: EMPTY_NDT, - green: EMPTY_NDT, - blue: EMPTY_NDT, - xValues: initialAxes, - yValues: initialAxes, - }); + // kept as the server answered, so a theme change redraws it without a poll + const [answer, setAnswer] = useState( + null, + ); + const data = useMemo(() => toChannels(answer, stripes), [answer, stripes]); /** Cached interval id */ const pollInterval = useRef | null>(null); @@ -123,50 +398,74 @@ 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]); // Poll during scan + once more afterwards useEffect(() => { - async function poll() { - if (!uuid) return; + let cancelled = false; + + async function poll(): Promise { + if (!uuid) return "error"; try { - const resp: SpectroscopyData = await fetchData(uuid); - setData({ - red: toRgbNdt(resp.RedTotal, "red"), - green: toRgbNdt(resp.GreenTotal, "green"), - blue: toRgbNdt(resp.BlueTotal, "blue"), - xValues: binEdgesToCentrePoints(resp.x_limits), - yValues: binEdgesToCentrePoints(resp.y_limits), - }); + const resp = await (kind === "cells" + ? fetchData(uuid) + : fetchRender(uuid, kind)); + if (cancelled) return "done"; // the kind or scan changed meanwhile + if ("notReady" in resp) return resp; // the next poll tries again + setAnswer(resp); } catch (err) { console.error("Polling error:", err); + return "error"; } + return "done"; + } + + /** The last poll after a scan ends: no later poll will follow a 503, so retry */ + async function finalPoll() { + // 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 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) { // start polling pollInterval.current = setInterval(poll, 500); // 2 Hz - } else if (!running && pollInterval.current) { - // poll once more then clear interval - poll().finally(() => { - clearInterval(pollInterval.current!); - pollInterval.current = null; - }); + } else if (!running && uuid) { + // The scan has ended: poll once more for its last points. (The previous + // run of this effect has already cleared the interval, so this can't + // depend on the interval still being set.) + void finalPoll(); } return () => { + cancelled = true; if (pollInterval.current) { clearInterval(pollInterval.current); pollInterval.current = null; } }; - }, [running, uuid]); + }, [running, uuid, kind]); - return { data, running }; + return { data, running, status }; } diff --git a/apps/visr/src/components/tomography/SliceViewer.tsx b/apps/visr/src/components/tomography/SliceViewer.tsx index 99041244..7ac17054 100644 --- a/apps/visr/src/components/tomography/SliceViewer.tsx +++ b/apps/visr/src/components/tomography/SliceViewer.tsx @@ -1,9 +1,15 @@ import { Box } from "@mui/material"; -import { Plane } from "./PlaneEnum"; import { HeatmapPlot } from "@diamondlightsource/davidia"; import ndarray from "ndarray"; import createArrayFromView from "../../utils/createArrayFromView"; +// Not importing Plane in case we move SliceViewer somewhere else as a generic component +enum Plane { + X, + Y, + Z, +} + interface Props { volumeData: Uint8Array; volumeShape: [number, number, number]; diff --git a/apps/visr/src/components/tomography/TomographyControls.tsx b/apps/visr/src/components/tomography/TomographyControls.tsx index 30f32c62..65598a68 100644 --- a/apps/visr/src/components/tomography/TomographyControls.tsx +++ b/apps/visr/src/components/tomography/TomographyControls.tsx @@ -6,7 +6,7 @@ import { Slider, Typography, } from "@mui/material"; -import { Plane } from "./PlaneEnum"; +import { Plane } from "./plane"; interface Props { onSlide: (event: Event, newValue: number | number[]) => void; diff --git a/apps/visr/src/components/tomography/TomographyPlots.tsx b/apps/visr/src/components/tomography/TomographyPlots.tsx index 598d0f3b..b45b54b7 100644 --- a/apps/visr/src/components/tomography/TomographyPlots.tsx +++ b/apps/visr/src/components/tomography/TomographyPlots.tsx @@ -3,7 +3,7 @@ import { ReactGridLayout, useContainerWidth } from "react-grid-layout"; import { useLayoutEffect, useState } from "react"; import SliceViewer from "./SliceViewer"; import VolumeRenderer from "./VolumeRenderer"; -import { Plane } from "./PlaneEnum"; +import { Plane } from "./plane"; // --------------------------------------------------------------------------- // Types diff --git a/apps/visr/src/components/tomography/TomographyView.tsx b/apps/visr/src/components/tomography/TomographyView.tsx index 64c832fa..010663de 100644 --- a/apps/visr/src/components/tomography/TomographyView.tsx +++ b/apps/visr/src/components/tomography/TomographyView.tsx @@ -2,9 +2,10 @@ import { Box } from "@mui/material"; import { useEffect, useState } from "react"; import TomographyControls from "./TomographyControls"; import { TomographyForm } from "./TomographyForm"; -import { Plane } from "./PlaneEnum"; +import { Plane } from "./plane"; import ControlsDrawer from "../ControlsDrawer"; import TomographyPlots from "./TomographyPlots"; +import { useSharedState } from "../../hooks/useSharedState"; // --------------------------------------------------------------------------- // Types @@ -21,13 +22,14 @@ interface Volume { function TomographyView() { const [volume, setVolume] = useState(null); - const [volumeVisible, setVolumeVisible] = useState(true); // const [revolve, setRevolve] = useState(false); - const [slice, setSlice] = useState(0); - const [plane, setPlane] = useState(Plane.Z); - const [threshold, setThreshold] = useState(38); // 38/255 ≈ 0.15, the renderer's old default const [drawerOpen, setDrawerOpen] = useState(true); + // Shared across all open tabs (and restored on reload) + const [slice, setSlice] = useSharedState("tomography:slice", 0); + const [plane, setPlane] = useSharedState("tomography:plane", Plane.Z); + const [threshold, setThreshold] = useSharedState("tomography:threshold", 38); // 38/255 ≈ 0.15, the renderer's old default + // ------------------------------------------------------------------------- // Load test volume // ------------------------------------------------------------------------- @@ -50,60 +52,33 @@ function TomographyView() { loadTestVolume(); }, []); - // ------------------------------------------------------------------------- - // Persist values across multiple open tabs - // ------------------------------------------------------------------------- - useEffect(() => { - localStorage.setItem("plane", plane.toString()); - localStorage.setItem("volumeVisible", volumeVisible.toString()); - localStorage.setItem("slice", slice.toString()); - }, [plane, volumeVisible, slice]); - - useEffect(() => { - const onReceiveMessage = (e: StorageEvent) => { - const { key, newValue } = e; - switch (key) { - case "plane": { - setPlane(Number(newValue)); - break; - } - case "volumeVisible": { - setVolumeVisible(Boolean(newValue)); - break; - } - case "slice": { - setSlice(Number(newValue)); - break; - } - } - }; - window.addEventListener("storage", onReceiveMessage); - return () => { - window.removeEventListener("storage", onReceiveMessage); - }; - }, []); - // ------------------------------------------------------------------------- // Handlers // ------------------------------------------------------------------------- - const handleSlider = (event: Event, newValue: number | number[]) => { - const slice = typeof newValue == "number" ? newValue : newValue[0]; - setSlice(slice); + const handleSlider = (_event: Event, newValue: number | number[]) => { + setSlice(typeof newValue === "number" ? newValue : newValue[0]); }; const handlePlane = (event: React.ChangeEvent) => { - const radioValue = Number((event.target as HTMLInputElement).value); - setPlane(radioValue); + setPlane(Number(event.target.value) as Plane); }; - const handleThreshold = (event: Event, newValue: number | number[]) => { - setThreshold(typeof newValue == "number" ? newValue : newValue[0]); + const handleThreshold = (_event: Event, newValue: number | number[]) => { + setThreshold(typeof newValue === "number" ? newValue : newValue[0]); }; // revolve to be implemented if (!volume) return ; + // Another tab may switch to an axis with fewer slices, so clamp locally + const maxSlice: Record = { + [Plane.Z]: volume.volumeShape[0] - 1, + [Plane.Y]: volume.volumeShape[1] - 1, + [Plane.X]: volume.volumeShape[2] - 1, + }; + const clampedSlice = Math.min(slice, maxSlice[plane]); + // ------------------------------------------------------------------------- // Render // ------------------------------------------------------------------------- @@ -118,9 +93,9 @@ function TomographyView() { > setDrawerOpen(prev => !prev)} controls={[ - , + , , diff --git a/apps/visr/src/components/tomography/PlaneEnum.tsx b/apps/visr/src/components/tomography/plane.tsx similarity index 100% rename from apps/visr/src/components/tomography/PlaneEnum.tsx rename to apps/visr/src/components/tomography/plane.tsx diff --git a/apps/visr/src/hooks/useSharedState.ts b/apps/visr/src/hooks/useSharedState.ts new file mode 100644 index 00000000..6d99f8b1 --- /dev/null +++ b/apps/visr/src/hooks/useSharedState.ts @@ -0,0 +1,75 @@ +import { useCallback, useMemo, useSyncExternalStore } from "react"; + +// The `storage` event only fires in *other* tabs, so same-tab +// subscribers are notified through this map instead. +const listeners = new Map void>>(); + +/** + * Like useState, but persisted to localStorage and kept in sync + * across every open tab on the same origin. + */ +export function useSharedState(key: string, initial: T) { + const subscribe = useCallback( + (onChange: () => void) => { + const onStorage = (e: StorageEvent) => { + if (e.key === key) onChange(); + }; + window.addEventListener("storage", onStorage); + + const set = listeners.get(key) ?? new Set(); + listeners.set(key, set); + set.add(onChange); + + return () => { + window.removeEventListener("storage", onStorage); + set.delete(onChange); + }; + }, + [key], + ); + + const getSnapshot = () => { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }; + + const raw = useSyncExternalStore(subscribe, getSnapshot, () => null); + + const value = useMemo(() => { + if (raw === null) return initial; + try { + return JSON.parse(raw) as T; + } catch { + return initial; + } + // `initial` is only a fallback; re-parse when the stored value changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [raw]); + + const setValue = useCallback( + (next: T | ((prev: T) => T)) => { + let prev = initial; + try { + const current = localStorage.getItem(key); + if (current !== null) prev = JSON.parse(current) as T; + } catch { + /* fall back to initial */ + } + const resolved = + typeof next === "function" ? (next as (p: T) => T)(prev) : next; + try { + localStorage.setItem(key, JSON.stringify(resolved)); + } catch { + /* storage full or blocked; ignore */ + } + listeners.get(key)?.forEach(l => l()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [key], + ); + + return [value, setValue] as const; +}