diff --git a/apps/visr/src/components/ControlsDrawer.tsx b/apps/visr/src/components/ControlsDrawer.tsx index 40ff29c1..85003b27 100644 --- a/apps/visr/src/components/ControlsDrawer.tsx +++ b/apps/visr/src/components/ControlsDrawer.tsx @@ -31,6 +31,7 @@ function ControlsDrawer({ boxSizing: "border-box", bgcolor: "transparent", overflowY: "auto", + flexShrink: 0, }, }} > @@ -40,7 +41,7 @@ function ControlsDrawer({ alignItems: "center", justifyContent: "space-between", px: 2, - height: 64, + height: 80, flexShrink: 0, }} > diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx index 2a2429ce..5c32835e 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyPlots.tsx @@ -1,9 +1,13 @@ +import { Box, Typography } from "@mui/material"; import { ImagePlot, type NDT } from "@diamondlightsource/davidia"; import ndarray from "ndarray"; +import { ReactGridLayout, useContainerWidth } from "react-grid-layout"; +import { useLayoutEffect, useState, type ComponentProps } from "react"; import { useSpectroscopyData, type RGBColour } from "./useSpectroscopyData"; -import ReactGridLayout, { useContainerWidth } from "react-grid-layout"; -import { useMemo, type ComponentProps } from "react"; -import { Box } from "@mui/material"; + +// --------------------------------------------------------------------------- +// Helpers: data conversion +// --------------------------------------------------------------------------- function toNDT(matrix: (number | null)[][], colour: RGBColour): NDT { if (!matrix?.length || !matrix[0]?.length) { @@ -50,9 +54,14 @@ function toNDT(matrix: (number | null)[][], colour: RGBColour): NDT { return ndarray(rgb, [height, width, 3]) as NDT; } + /** Placeholder empty gray dataset */ const EMPTY_NDT = toNDT([[0]], "gray"); +// --------------------------------------------------------------------------- +// Helpers: data fetching +// --------------------------------------------------------------------------- + /** Return type of `/api/data/map` */ interface MapResponse { values: (number | null)[][]; @@ -71,20 +80,28 @@ async function fetchMap( return toNDT(mapResponse.values, colour); } +// --------------------------------------------------------------------------- +// Channel definitions +// --------------------------------------------------------------------------- + const CHANNELS = [ { key: "red", label: "Red channel" }, { key: "green", label: "Green channel" }, { key: "blue", label: "Blue channel" }, - //{ key: "gray", label: "Gray channel" }, // using gray channel to stop typing errors + //{ key: "gray", label: "Gray channel" }, ] as const; type ChannelKey = (typeof CHANNELS)[number]["key"]; type PlotValues = ComponentProps["values"]; export type SpectroscopyData = Partial>; +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + interface SpectroscopyPlotsProps { expanded: boolean; - plotAspectRatio: number; + plotAspectRatio: number | "auto" | "equal"; } function SpectroscopyPlots({ @@ -93,57 +110,139 @@ function SpectroscopyPlots({ }: SpectroscopyPlotsProps) { const { data: channels } = useSpectroscopyData(fetchMap); const { width, containerRef, mounted } = useContainerWidth(); - const h = 10; - const w = 1; + // ------------------------------------------------------------------------- + // Grid sizing + // ------------------------------------------------------------------------- + + const totalRows = 10; + const rowsPerPlot = expanded ? totalRows / 2 : totalRows; + const w = 1; + const minimumRowHeight = 20; + const minimumGridHeight = minimumRowHeight * totalRows; + const [containerHeight, setContainerHeight] = useState(0); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver(([entry]) => { + setContainerHeight(entry.contentRect.height); + }); + resizeObserver.observe(container); + + return () => resizeObserver.disconnect(); + }, [containerRef]); + + const rowHeight = + containerHeight > 0 + ? Math.max(containerHeight / totalRows, minimumRowHeight) + : expanded + ? minimumRowHeight + : 50; + const gridHeight = rowHeight * totalRows; + + // ------------------------------------------------------------------------- + // Layout: 3x1 collapsed, 2x2 expanded + // ------------------------------------------------------------------------- const layout = [ - { i: "0", x: 0, y: 0, w: w, h: h, static: true }, - { i: "1", x: 1, y: 0, w: w, h: h, static: true }, + { i: "0", x: 0, y: 0, w: w, h: rowsPerPlot, static: true }, + { i: "1", x: 1, y: 0, w: w, h: rowsPerPlot, static: true }, { i: "2", - x: !expanded ? 2 : 0, - y: !expanded ? 0 : h, + x: expanded ? 0 : 2, + y: expanded ? rowsPerPlot : 0, w: w, - h: h, + h: rowsPerPlot, static: true, }, - // { i: "3", x: !expanded ? 3 : 1, !expanded ? 0 : h, w: w, h: h, static: true }, ]; - const plots = useMemo( - () => - CHANNELS.map(({ key }, i) => ( - - - - )), - [channels, plotAspectRatio], - ); + // ------------------------------------------------------------------------- + // Views + // ------------------------------------------------------------------------- + const views = CHANNELS.map(({ key }) => ( + + )); + + const titles = CHANNELS.map(({ label }) => label); + + // ------------------------------------------------------------------------- + // Plot cells + // ------------------------------------------------------------------------- + const plots = views.map((view, i) => ( + + + {titles[i]} + + with no height, + // which breaks the size chain. This gives it one. + "& > div": { height: "100%" }, + }} + > + {view} + + + )); + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- return ( - }> + } + 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 eed9ce36..137f41fa 100644 --- a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx +++ b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx @@ -16,10 +16,20 @@ export type SpectroscopyFormData = { exposure_time: number; }; +// --------------------------------------------------------------------------- +// Layout constants +// --------------------------------------------------------------------------- + +const DRAWER_COLLAPSED_HEIGHT = 80; +const NAVBAR_HEIGHT = 48; +const PLOT_ASPECT_RATIO = "equal"; + function SpectroscopyView() { const [drawerOpen, setDrawerOpen] = useState(true); - // set off workflow when scan ends + // ------------------------------------------------------------------------- + // Set off workflow when scan ends + // ------------------------------------------------------------------------- const scanEvent = useScanEvents(); const { instrumentSession } = useInstrumentSession(); @@ -37,12 +47,14 @@ function SpectroscopyView() { "input-file-path": scanEvent.filepath, }); } - }); - - const DRAWER_COLLAPSED_HEIGHT = 100; - const NAVBAR_HEIGHT = 32; - const PLOT_ASPECT_RATIO = 0.75; + }, [scanEvent, instrumentSession, submitWorkflow]); + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + // Flex column, not grid: the plots take the remaining space via flex:1 1 0 + // and the drawer keeps its own height. A grid would split the spare height + // evenly between the two rows instead. return ( - - - Camera View - - - - - - - - ); -} diff --git a/apps/visr/src/components/tomography/SliceViewer.tsx b/apps/visr/src/components/tomography/SliceViewer.tsx index 132ad5fa..0ed5f855 100644 --- a/apps/visr/src/components/tomography/SliceViewer.tsx +++ b/apps/visr/src/components/tomography/SliceViewer.tsx @@ -1,4 +1,4 @@ -import { Box, Typography } from "@mui/material"; +import { Box } from "@mui/material"; import { Plane } from "./PlaneEnum"; import { HeatmapPlot } from "@diamondlightsource/davidia"; import ndarray from "ndarray"; @@ -9,12 +9,14 @@ interface Props { volumeShape: [number, number, number]; plane: Plane; slice: number; + resizeKey?: string; //if this depends on a value, the plot will remount every time that value changes. If no need for it to remound, leave as undefined } export default function SliceViewer({ volumeData, volumeShape, plane, slice, + resizeKey, }: Props) { if (volumeData == undefined || volumeShape == undefined) { return ; @@ -39,44 +41,13 @@ export default function SliceViewer({ } return ( - - - - Slice View - - -
- -
-
+ ); } diff --git a/apps/visr/src/components/tomography/TomographyPlots.tsx b/apps/visr/src/components/tomography/TomographyPlots.tsx index d0d33376..d309cc2d 100644 --- a/apps/visr/src/components/tomography/TomographyPlots.tsx +++ b/apps/visr/src/components/tomography/TomographyPlots.tsx @@ -1,9 +1,13 @@ -import { Box } from "@mui/material"; -import CameraViewer from "./CameraViewer"; -import VolumeViewer from "./VolumeViewer"; +import { Box, Typography } from "@mui/material"; +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 { ReactGridLayout, useContainerWidth } from "react-grid-layout"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- interface Props { volumeData: Uint8Array; @@ -14,32 +18,89 @@ interface Props { plane: Plane; // revolve?: boolean } + +// --------------------------------------------------------------------------- +// Camera view +// --------------------------------------------------------------------------- + +function CamPva() { + const [imgSrc, setImgSrc] = useState( + "https://visr-pvws.diamond.ac.uk/mjpg/BL01B-DI-CAM-01:PVA:OUTPUT", + ); + + return ( + setImgSrc("../../../test-data/seal.png")} + alt="Camera view" + /> + ); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + function TomographyPlots({ volumeData, volumeShape, - volumeVisible, plane, slice, drawerOpen, }: Props) { const { width, containerRef, mounted } = useContainerWidth(); + + // ------------------------------------------------------------------------- + // Grid sizing + // ------------------------------------------------------------------------- const h = 10; const w = 1; + const minimumRowHeight = 30; + const minimumGridHeight = minimumRowHeight * h; + const [containerHeight, setContainerHeight] = useState(0); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver(([entry]) => { + setContainerHeight(entry.contentRect.height); + }); + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, [containerRef]); + + const rowHeight = + containerHeight > 0 + ? Math.max(containerHeight / h, minimumRowHeight) + : drawerOpen + ? minimumRowHeight + : 50; + const gridHeight = rowHeight * h; + + // ------------------------------------------------------------------------- + // Layout: 3x1 + // ------------------------------------------------------------------------- const layout = [ { i: "0", x: 0, y: 0, w: w, h: h, static: true }, { i: "1", x: 1, y: 0, w: w, h: h, static: true }, - { i: "2", x: drawerOpen ? 2 : 2, y: 0, w: w, h: h, static: true }, + { i: "2", x: 2, y: 0, w: w, h: h, static: true }, ]; if (!volumeData) return ; + // ------------------------------------------------------------------------- + // Views + // ------------------------------------------------------------------------- const views = [ - , - , + , , ]; + + const titles = ["Camera View", "Reconstruction", "Slice View"]; + + // ------------------------------------------------------------------------- + // Plot cells + // ------------------------------------------------------------------------- const plots = views.map((view, i) => ( - {view} + + {titles[i]} + + with no height, + // which breaks the size chain. This gives it one. + "& > div": { height: "100%" }, + }} + > + {view} + )); - console.log(plots[0]); + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- return ( - } color="blue"> + } + sx={{ + flex: "1 1 0", + width: "100%", + minWidth: 0, + height: gridHeight, + minHeight: minimumGridHeight, + overflow: "visible", + }} + > {mounted && ( {}} > {plots} diff --git a/apps/visr/src/components/tomography/TomographyView.tsx b/apps/visr/src/components/tomography/TomographyView.tsx index ea6b1a99..bbd05410 100644 --- a/apps/visr/src/components/tomography/TomographyView.tsx +++ b/apps/visr/src/components/tomography/TomographyView.tsx @@ -6,11 +6,26 @@ import { Plane } from "./PlaneEnum"; import ControlsDrawer from "../ControlsDrawer"; import TomographyPlots from "./TomographyPlots"; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + interface Volume { volumeData: Uint8Array; volumeShape: [number, number, number]; } +// --------------------------------------------------------------------------- +// Layout constants +// --------------------------------------------------------------------------- + +const DRAWER_COLLAPSED_HEIGHT = 80; +const NAVBAR_HEIGHT = 32; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + function TomographyView() { const [volume, setVolume] = useState(null); const [volumeVisible, setVolumeVisible] = useState(true); @@ -18,9 +33,10 @@ function TomographyView() { const [slice, setSlice] = useState(0); const [plane, setPlane] = useState(Plane.Z); const [drawerOpen, setDrawerOpen] = useState(true); - const DRAWER_COLLAPSED_HEIGHT = 80; - const NAVBAR_HEIGHT = 32; + // ------------------------------------------------------------------------- + // Load test volume + // ------------------------------------------------------------------------- useEffect(() => { async function loadTestVolume() { const [metaRes, rawRes] = await Promise.all([ @@ -40,7 +56,9 @@ function TomographyView() { loadTestVolume(); }, []); - //use local storage to persist values across multiple open tabs + // ------------------------------------------------------------------------- + // Persist values across multiple open tabs + // ------------------------------------------------------------------------- useEffect(() => { localStorage.setItem("plane", plane.toString()); localStorage.setItem("volumeVisible", volumeVisible.toString()); @@ -71,6 +89,9 @@ function TomographyView() { }; }, []); + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- const handleSlider = (event: Event, newValue: number | number[]) => { const slice = typeof newValue == "number" ? newValue : newValue[0]; setSlice(slice); @@ -85,6 +106,9 @@ function TomographyView() { if (!volume) return ; + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- return ( , ]} /> diff --git a/apps/visr/src/components/tomography/VolumeViewer.tsx b/apps/visr/src/components/tomography/VolumeViewer.tsx deleted file mode 100644 index 13ae0341..00000000 --- a/apps/visr/src/components/tomography/VolumeViewer.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Box, Typography } from "@mui/material"; -import VolumeRenderer from "./VolumeRenderer"; - -interface Props { - volumeData: Uint8Array; - volumeShape: [number, number, number]; - visible: boolean; - // revolve?: boolean -} - -// Currently loads a static test volume from public/test-data/ -export default function VolumeViewer({ - volumeData, - volumeShape, - visible, -}: Props) { - return ( - - - - Reconstruction - - - - {visible && volumeData && volumeShape ? ( - - ) : ( - - ... - - )} - - ); -} diff --git a/apps/visr/src/utils/createArrayFromView.ts b/apps/visr/src/utils/createArrayFromView.ts index 3266f182..0fc5c9b2 100644 --- a/apps/visr/src/utils/createArrayFromView.ts +++ b/apps/visr/src/utils/createArrayFromView.ts @@ -1,6 +1,7 @@ import ndarray, { type NdArray } from "ndarray"; import { assign } from "ndarray-ops"; +//copied (and simplified) from h5web: https://github.com/silx-kit/h5web/blob/a8cceed504b8ab57d426319db56b8b8dab309e3f/packages/shared/src/vis-utils.ts#L103 export default function createArrayFromView( view: NdArray, ): NdArray {