diff --git a/apps/visr/src/components/spectroscopy/ControlsDrawer.tsx b/apps/visr/src/components/spectroscopy/ControlsDrawer.tsx
new file mode 100644
index 00000000..2090452e
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/ControlsDrawer.tsx
@@ -0,0 +1,55 @@
+import { Drawer, Box, IconButton, Typography } from "@mui/material";
+import UnfoldLessIcon from "@mui/icons-material/UnfoldLess";
+import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
+import { SpectroscopyForm } from "./SpectroscopyForm";
+
+interface ControlsDrawerProps {
+ open: boolean;
+ collapsedHeight: number;
+ onToggle: () => void;
+}
+
+function ControlsDrawer({
+ open,
+ collapsedHeight,
+ onToggle,
+}: ControlsDrawerProps) {
+ return (
+
+
+ Controls
+
+ {open ? : }
+
+
+
+ {open ? : }
+
+
+ );
+}
+
+export default ControlsDrawer;
diff --git a/apps/visr/src/components/spectroscopy/PlotFrame.tsx b/apps/visr/src/components/spectroscopy/PlotFrame.tsx
new file mode 100644
index 00000000..33f454ee
--- /dev/null
+++ b/apps/visr/src/components/spectroscopy/PlotFrame.tsx
@@ -0,0 +1,111 @@
+import { Box } from "@mui/material";
+import { Children, type ReactNode } from "react";
+
+const DEFAULT_GAP_PX = 8;
+const TEST_BGCOLOR = "#ff000033";
+
+export interface PlotFrameProps {
+ /** Drives the layout: `true` → contained 2x2 grid, `false` → single filling row. */
+ expanded: boolean;
+ /** Plot elements to lay out. */
+ children: ReactNode;
+ /** Cell aspectRatio ratio as a CSS `aspectRatio-ratio` string, e.g. `"4 / 3"`. */
+ aspectRatio: string;
+ /** Gap between cells, in pixels. */
+ gap?: number;
+ /** Cell background colour (any CSS colour). */
+ bgcolor?: string;
+}
+
+/**
+ * Lays out plot children in one of two states:
+ * - expanded: a contained 2x2 grid — every cell keeps aspectRatio ratio and the
+ * whole grid fits inside its parent, leaving dead space if needed.
+ * - collapsed: a single row that fills the width; the height follows the ratio
+ * and the root is content-height, so a sibling (e.g. a drawer)
+ * can take the remaining space.
+ *
+ * Layout-only and plot-agnostic: pass already-created plot elements as children.
+ */
+export default function PlotFrame({
+ expanded,
+ children,
+ aspectRatio,
+ gap = DEFAULT_GAP_PX,
+ bgcolor = TEST_BGCOLOR,
+}: PlotFrameProps) {
+ const [rw, rh] = aspectRatio.split("/").map(n => parseFloat(n));
+ const ar = rw / rh;
+
+ // (expanded only): clamp the cell by the width fit and the height fit.
+ const cellW = `min((100cqw - ${gap}px) / 2, ${ar} * (100cqh - ${gap}px) / 2)`;
+ const cellH = `min((100cqh - ${gap}px) / 2, (100cqw - ${gap}px) / (2 * ${ar}))`;
+
+ const cellSizeSx = expanded
+ ? { width: cellW, height: cellH }
+ : { flex: 1, aspectRatio };
+
+ return (
+
+
+ {Children.map(children, child => (
+ // Outer cell: layout-sized only. Its size comes purely from cellSizeSx,
+ // never from the plot's content.
+
+ {/* Absolute fill: matches the cell exactly and is out of flow, so the
+ h5web canvas inside can't feed its size back into the cell. */}
+
+ {child}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/visr/src/components/spectroscopy/RawSpectroscopyData.tsx b/apps/visr/src/components/spectroscopy/RawSpectroscopyData.tsx
index 639be16b..adc226cb 100644
--- a/apps/visr/src/components/spectroscopy/RawSpectroscopyData.tsx
+++ b/apps/visr/src/components/spectroscopy/RawSpectroscopyData.tsx
@@ -1,7 +1,9 @@
import { ImagePlot, type NDT } from "@diamondlightsource/davidia";
-import Box from "@mui/material/Box";
import ndarray from "ndarray";
import { useSpectroscopyData, type RGBColour } from "./useSpectroscopyData";
+import PlotFrame from "./PlotFrame";
+
+import { useMemo, type ComponentProps } from "react";
type RGBColor = "red" | "green" | "blue" | "gray";
@@ -71,47 +73,47 @@ async function fetchMap(
return toNDT(mapResponse.values, colour);
}
-function RawSpectroscopyData() {
- const { data } = useSpectroscopyData(fetchMap);
+const CHANNELS = [
+ { key: "red", label: "Red channel" },
+ { key: "green", label: "Green channel" },
+ { key: "blue", label: "Blue channel" },
+ { key: "red", label: "Alpha channel" }, // using red channel again for now to stop typing errors
+] as const;
+
+type ChannelKey = (typeof CHANNELS)[number]["key"];
+type PlotValues = ComponentProps["values"];
+export type SpectroscopyData = Partial>;
+
+interface RawSpectroscopyDataProps {
+ expanded: boolean;
+ plotAspectRatio: string;
+}
+
+function RawSpectroscopyData({
+ expanded,
+ plotAspectRatio,
+}: RawSpectroscopyDataProps) {
+
+ const { data: channels } = useSpectroscopyData(fetchMap);
+
+ const plots = useMemo(
+ () =>
+ CHANNELS.map(({ key }, i) => (
+
+ )),
+ [channels],
+ );
+
return (
-
-
-
-
-
-
-
+
+ {plots}
+
);
}
diff --git a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx
index e5855c1c..1f865fdf 100644
--- a/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx
+++ b/apps/visr/src/components/spectroscopy/SpectroscopyView.tsx
@@ -1,11 +1,11 @@
import { Box } from "@mui/material";
import RawSpectroscopyData from "./RawSpectroscopyData";
-import { SpectroscopyForm } from "./SpectroscopyForm";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useScanEvents } from "../../hooks/scanEvents";
import { useSubmitWorkflow } from "../../hooks/useSubmitWorkflow";
import { useInstrumentSession } from "../../context/instrumentSession/useInstrumentSession";
import { visitTextToVisit } from "../../utils/common";
+import ControlsDrawer from "./ControlsDrawer";
export type SpectroscopyFormData = {
total_number_of_scan_points: number;
@@ -16,6 +16,8 @@ export type SpectroscopyFormData = {
};
function SpectroscopyView() {
+ const [drawerOpen, setDrawerOpen] = useState(true);
+
// set off workflow when scan ends
const scanEvent = useScanEvents();
const { instrumentSession } = useInstrumentSession();
@@ -36,16 +38,29 @@ function SpectroscopyView() {
}
});
+ const DRAWER_COLLAPSED_HEIGHT = 64;
+ const NAVBAR_HEIGHT = 32;
+ const PLOT_ASPECT_RATIO = "4 / 3";
+
return (
-
-
+
+ setDrawerOpen(prev => !prev)}
+ />
);
}