From 84f9cf1b91d07f05f37fa008f10617f8c8ac5b24 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 13:36:17 +0100 Subject: [PATCH 01/32] feat!: roi selection plugin --- package.json | 5 +- pnpm-lock.yaml | 49 +++ pnpm-workspace.yaml | 1 + roi-selector/package.json | 43 +++ roi-selector/src/RoiSelector.tsx | 434 +++++++++++++++++++++++++ roi-selector/src/index.tsx | 1 + roi-selector/tsconfig.json | 14 + roi-selector/vite.config.js | 36 ++ sites/app/package.json | 1 + sites/app/src/App.tsx | 15 +- sites/app/vite.config.js | 3 + viewer/src/components/Viewer.tsx | 104 +++++- viewer/src/components/VizarrViewer.tsx | 8 +- viewer/src/index.tsx | 5 + viewer/src/state.ts | 28 ++ 15 files changed, 737 insertions(+), 10 deletions(-) create mode 100644 roi-selector/package.json create mode 100644 roi-selector/src/RoiSelector.tsx create mode 100644 roi-selector/src/index.tsx create mode 100644 roi-selector/tsconfig.json create mode 100644 roi-selector/vite.config.js diff --git a/package.json b/package.json index 5fce2f20..27935ae2 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "fix": "biome check --write", "check": "pnpm build:viewer && pnpm -r run check", "build:viewer": "pnpm --filter vizarr build", + "build:roi-selector": "pnpm --filter @biongff/roi-selector build", "build:app": "pnpm --filter app build", - "build": "pnpm build:viewer && pnpm build:app", - "test": "vitest" + "test": "vitest", + "build": "pnpm build:viewer && pnpm build:roi-selector && pnpm build:app" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1119d24..dd70167a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,8 +65,57 @@ importers: specifier: ^4.0.15 version: 4.0.15(@types/node@24.3.0)(yaml@2.8.2) + roi-selector: + dependencies: + '@biongff/vizarr': + specifier: workspace:* + version: link:../viewer + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@18.3.18)(react@18.3.1) + '@emotion/styled': + specifier: ^11.14.1 + version: 11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1) + '@mui/icons-material': + specifier: ^7.2.0 + version: 7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.18)(react@18.3.1) + '@mui/material': + specifier: ^7.2.0 + version: 7.3.4(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + jotai: + specifier: ^1.0.0 + version: 1.13.1(@babel/core@7.26.9)(@babel/template@7.26.9)(react@18.3.1) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.10 + version: 18.3.18 + '@types/react-dom': + specifier: ^18.2.18 + version: 18.3.5(@types/react@18.3.18) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.3.4(vite@6.2.7(@types/node@24.3.0)) + typescript: + specifier: ^5.8.2 + version: 5.8.2 + vite: + specifier: ^6.2.7 + version: 6.2.7(@types/node@24.3.0)(yaml@2.8.2) + vitest: + specifier: ^4.0.15 + version: 4.0.15(@types/node@24.3.0)(yaml@2.8.2) + sites/app: dependencies: + '@biongff/roi-selector': + specifier: workspace:* + version: link:../../roi-selector '@biongff/vizarr': specifier: workspace:* version: link:../../viewer diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 88f2d98a..eeaed04f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - 'viewer' + - 'roi-selector' - 'sites/*' \ No newline at end of file diff --git a/roi-selector/package.json b/roi-selector/package.json new file mode 100644 index 00000000..461a5a01 --- /dev/null +++ b/roi-selector/package.json @@ -0,0 +1,43 @@ +{ + "name": "@biongff/roi-selector", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "dist/biongff-roi-selector.cjs.js", + "module": "dist/biongff-roi-selector.es.js", + "types": "dist/index.d.ts", + "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/biongff-roi-selector.es.js", + "require": "./dist/biongff-roi-selector.cjs.js" + } + }, + "scripts": { + "dev": "vite", + "build": "npm run check && vite build", + "preview": "vite preview", + "check": "tsc" + }, + "dependencies": { + "@biongff/vizarr": "workspace:*" + }, + "peerDependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.2.0", + "@mui/material": "^7.2.0", + "jotai": "^1.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.3.10", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.8.2", + "vite": "^6.2.7", + "vite-plugin-dts": "^4.5.4" + } +} diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx new file mode 100644 index 00000000..f6d53020 --- /dev/null +++ b/roi-selector/src/RoiSelector.tsx @@ -0,0 +1,434 @@ +import { ContentCopy, CropFree, HighlightAlt } from "@mui/icons-material"; +import { + Box, + Button, + Collapse, + Grid, + IconButton, + Snackbar, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { useAtom, useAtomValue } from "jotai"; +import React, { useEffect, useState } from "react"; + +import { useViewState, currentZInfoAtom, roiDrawStateAtom, viewportAtom } from "@biongff/vizarr"; + +/** + * RoiSelector — a collapsible panel that lets you: + * + * 1. Type in top-left (x₁, y₁) and bottom-right (x₂, y₂) image + * coordinates and zoom the viewer to that bounding box. + * 2. See the coordinates of the region currently in view. + * 3. Copy those coordinates to the clipboard with one click. + * + * How does the zoom math work? + * ---------------------------- + * deck.gl's OrthographicView uses a `viewState` with: + * • `target: [x, y]` — the image coordinate at the center of the viewport + * • `zoom` — a log₂ scale factor (zoom 0 = 1:1 pixels, zoom -1 = 50 %, etc.) + * + * Given an ROI defined by (x₁,y₁)–(x₂,y₂) and a viewport of size (W,H): + * target = center of the ROI = [(x₁+x₂)/2 , (y₁+y₂)/2] + * zoom = log₂( min(W / roiWidth, H / roiHeight) ) + * + * This is exactly the same formula used by `fitImageToViewport` in utils.ts, + * except here we apply it to the user-supplied ROI rectangle instead of the + * full image extent. + */ +function RoiSelector() { + // -------- local UI state -------- + const [open, setOpen] = useState(false); + + // The four text fields (kept as strings so the user can type freely): + const [x1, setX1] = useState(""); + const [y1, setY1] = useState(""); + const [x2, setX2] = useState(""); + const [y2, setY2] = useState(""); + + // Z-axis slice fields: + const [z1, setZ1] = useState(""); + const [z2, setZ2] = useState(""); + + // Snackbar feedback after clipboard copy: + const [snackOpen, setSnackOpen] = useState(false); + + // -------- Z-axis info from first layer -------- + const zInfo = useAtomValue(currentZInfoAtom); + const hasZAxis = zInfo !== null; + + // -------- draw-on-image state -------- + // This Jotai atom is shared with . Setting it to "waiting-first" + // tells the Viewer to intercept the next two clicks as ROI corners. + const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); + const isDrawing = roiDrawState !== null; + + // Listen for the custom event that Viewer dispatches after the second click. + // This is how the drawn coordinates flow back into our text fields. + useEffect(() => { + const handler = (e: Event) => { + const { corner1, corner2, z1: eventZ1, z2: eventZ2 } = (e as CustomEvent).detail; + setX1(String(corner1[0])); + setY1(String(corner1[1])); + setX2(String(corner2[0])); + setY2(String(corner2[1])); + setZ1(String(Math.min(eventZ1, eventZ2))); + setZ2(String(Math.max(eventZ1, eventZ2))); + }; + window.addEventListener("vizarr-roi-drawn", handler); + return () => window.removeEventListener("vizarr-roi-drawn", handler); + }, []); + + /** + * Toggle draw mode on/off. + * When activated, the cursor becomes a crosshair and the next two clicks + * on the image will define the ROI corners. + */ + const handleToggleDraw = () => { + if (isDrawing) { + setRoiDrawState(null); // cancel + } else { + setRoiDrawState("waiting-first"); + } + }; + + // -------- shared viewer state -------- + // `viewState` holds the current zoom / target the viewer is showing. + // `setViewState` lets us drive the camera programmatically. + const [viewState, setViewState] = useViewState(); + + // `viewport` gives us the pixel dimensions of the deck.gl canvas so we know + // how large the browser window is — needed to compute the zoom level. + const viewport = useAtomValue(viewportAtom); + + // -------- handlers -------- + + /** + * Called when the user clicks "Go to ROI". + * Parses the four coordinate fields, computes the target & zoom, and + * pushes the new viewState into the Jotai atom (which re-renders ). + */ + const handleGoToRoi = () => { + const nx1 = Number(x1); + const ny1 = Number(y1); + const nx2 = Number(x2); + const ny2 = Number(y2); + + // Guard: all four must be valid numbers + if ([nx1, ny1, nx2, ny2].some(Number.isNaN)) return; + // Guard: the ROI must have non-zero area + if (nx1 === nx2 || ny1 === ny2) return; + // Guard: need viewport dimensions to compute zoom + if (!viewport) return; + + // Ensure min/max are correct regardless of input order + const minX = Math.min(nx1, nx2); + const maxX = Math.max(nx1, nx2); + const minY = Math.min(ny1, ny2); + const maxY = Math.max(ny1, ny2); + + const roiWidth = maxX - minX; + const roiHeight = maxY - minY; + + // A small padding (in screen-pixels) so the ROI doesn't touch the edges: + const padding = 40; + const availableWidth = viewport.width - 2 * padding; + const availableHeight = viewport.height - 2 * padding; + + // The zoom level that fits the ROI inside the viewport: + const zoom = Math.log2(Math.min(availableWidth / roiWidth, availableHeight / roiHeight)); + + setViewState({ + zoom, + target: [(minX + maxX) / 2, (minY + maxY) / 2], + width: viewport.width, + height: viewport.height, + }); + }; + + /** + * Computes the bounding box of the currently visible region and returns it + * as { x1, y1, x2, y2 } in image coordinates. + * + * The math is the inverse of the zoom formula: + * scale = 2^zoom + * half-visible width = (viewportWidth / scale) / 2 + * half-visible height = (viewportHeight / scale) / 2 + * top-left = target - half-visible + * bottom-right = target + half-visible + */ + const getCurrentRoi = () => { + if (!viewState || !viewport) return null; + const scale = 2 ** viewState.zoom; + const halfW = viewport.width / scale / 2; + const halfH = viewport.height / scale / 2; + return { + x1: Math.round(viewState.target[0] - halfW), + y1: Math.round(viewState.target[1] - halfH), + x2: Math.round(viewState.target[0] + halfW), + y2: Math.round(viewState.target[1] + halfH), + }; + }; + + /** + * Fills the text fields with the coordinates of the current view. + * Handy for tweaking a region you navigated to manually. + */ + const handleFillFromView = () => { + const roi = getCurrentRoi(); + if (!roi) return; + setX1(String(roi.x1)); + setY1(String(roi.y1)); + setX2(String(roi.x2)); + setY2(String(roi.y2)); + if (hasZAxis) { + const currentZ = String(zInfo.zValue); + setZ1(currentZ); + setZ2(currentZ); + } + }; + + /** + * Copies the ROI coordinates (including Z if available) to the clipboard + * as a JSON string. + */ + const handleCopyToClipboard = () => { + const roi = getCurrentRoi(); + if (!roi) return; + const payload: Record = { ...roi }; + if (hasZAxis) { + const nz1 = z1 !== "" ? Number(z1) : zInfo.zValue; + const nz2 = z2 !== "" ? Number(z2) : zInfo.zValue; + payload.z1 = Math.min(nz1, nz2); + payload.z2 = Math.max(nz1, nz2); + } + const text = JSON.stringify(payload); + navigator.clipboard.writeText(text).then(() => { + setSnackOpen(true); + }); + }; + + // -------- render -------- + + return ( + + {/* Toggle button */} + + setOpen((prev) => !prev)} + sx={{ color: "#fff" }} + > + + + ROI Selection + + + + + {/* Collapsible panel */} + + + {/* ---- Top-left coordinate ---- */} + + Top-left (x₁, y₁) + + + + setX1(e.target.value)} + fullWidth + slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} + /> + + + setY1(e.target.value)} + fullWidth + slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} + /> + + + + {/* ---- Bottom-right coordinate ---- */} + + Bottom-right (x₂, y₂) + + + + setX2(e.target.value)} + fullWidth + slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} + /> + + + setY2(e.target.value)} + fullWidth + slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} + /> + + + + {/* ---- Z-axis range (only shown when data has a Z axis) ---- */} + {hasZAxis && ( + <> + + Z range (slice) + + + + setZ1(e.target.value)} + fullWidth + slotProps={{ + input: { sx: { color: "#fff", fontSize: 12 } }, + htmlInput: { min: 0, max: zInfo.zMax }, + }} + /> + + + setZ2(e.target.value)} + fullWidth + slotProps={{ + input: { sx: { color: "#fff", fontSize: 12 } }, + htmlInput: { min: 0, max: zInfo.zMax }, + }} + /> + + + + )} + + {/* ---- Action buttons ---- */} + + + + + + + + + + {/* ---- Draw on image button ---- */} + + + {/* ---- Current view info + copy button ---- */} + {viewState && viewport && ( + + + + Current view + + {(() => { + const roi = getCurrentRoi(); + if (!roi) return null; + const zText = hasZAxis && z1 !== "" && z2 !== "" ? ` z:[${z1}–${z2}]` : ""; + return ( + + ({roi.x1}, {roi.y1}) → ({roi.x2}, {roi.y2}){zText} + + ); + })()} + + + + + + + + )} + + + + {/* Snackbar shown briefly after a successful copy */} + setSnackOpen(false)} + message="ROI coordinates copied!" + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + ); +} + +export default RoiSelector; diff --git a/roi-selector/src/index.tsx b/roi-selector/src/index.tsx new file mode 100644 index 00000000..9e8752e2 --- /dev/null +++ b/roi-selector/src/index.tsx @@ -0,0 +1 @@ +export { default as RoiSelector } from "./RoiSelector"; diff --git a/roi-selector/tsconfig.json b/roi-selector/tsconfig.json new file mode 100644 index 00000000..62b84831 --- /dev/null +++ b/roi-selector/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "types": ["vite/client"] + } +} diff --git a/roi-selector/vite.config.js b/roi-selector/vite.config.js new file mode 100644 index 00000000..627ce828 --- /dev/null +++ b/roi-selector/vite.config.js @@ -0,0 +1,36 @@ +import path from "node:path"; + +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), dts({ rollupTypes: true, tsconfigPath: "./tsconfig.json" })], + build: { + lib: { + entry: path.resolve(__dirname, "src/index.tsx"), + name: "BiongffRoiSelector", + formats: ["es", "cjs"], + fileName: (format) => `biongff-roi-selector.${format}.js`, + }, + rollupOptions: { + external: [ + "react", + "react-dom", + "@mui/material", + "@mui/icons-material", + "@emotion/react", + "@emotion/styled", + "jotai", + "@biongff/vizarr", + ], + output: { + globals: { + react: "React", + "react-dom": "ReactDOM", + }, + }, + }, + }, +}); diff --git a/sites/app/package.json b/sites/app/package.json index 602497c1..a4773de4 100644 --- a/sites/app/package.json +++ b/sites/app/package.json @@ -9,6 +9,7 @@ "check": "tsc" }, "dependencies": { + "@biongff/roi-selector": "workspace:*", "@biongff/vizarr": "workspace:*", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/sites/app/src/App.tsx b/sites/app/src/App.tsx index 88695323..dd16136a 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -1,3 +1,4 @@ +import { RoiSelector } from "@biongff/roi-selector"; import { type ViewState, Vizarr } from "@biongff/vizarr"; import debounce from "just-debounce-it"; import * as React from "react"; @@ -20,12 +21,20 @@ function parseViewStateFromUrl(): ViewState | undefined { export default function App() { const urlString = window.location.href; - const { sources, viewState } = React.useMemo(() => { + const { sources, viewState, enableRoi } = React.useMemo(() => { const url = new URL(urlString); const { searchParams } = url; + + // Ensure `roi` param is always visible in the URL (default: "0") + if (!searchParams.has("roi")) { + searchParams.set("roi", "0"); + window.history.replaceState(window.history.state, "", decodeURIComponent(url.href)); + } + return { sources: searchParams.getAll("source"), viewState: parseViewStateFromUrl(), + enableRoi: searchParams.get("roi") === "1", }; }, [urlString]); @@ -48,7 +57,9 @@ export default function App() { return (
- + + {enableRoi && } +
); } diff --git a/sites/app/vite.config.js b/sites/app/vite.config.js index 8cdbb9ad..2fd211ef 100644 --- a/sites/app/vite.config.js +++ b/sites/app/vite.config.js @@ -3,6 +3,8 @@ import * as path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +const source = process.env.VIZARR_DATA || "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001253.zarr"; + export default defineConfig(({ mode }) => ({ plugins: [react()], base: "./", @@ -15,4 +17,5 @@ export default defineConfig(({ mode }) => ({ : {}), }, }, + server: { open: `?source=${source}` }, })); diff --git a/viewer/src/components/Viewer.tsx b/viewer/src/components/Viewer.tsx index b39b3b24..ac306854 100644 --- a/viewer/src/components/Viewer.tsx +++ b/viewer/src/components/Viewer.tsx @@ -1,11 +1,11 @@ import { ScaleBarLayer } from "@hms-dbmi/viv"; import DeckGL from "deck.gl"; -import { OrthographicView } from "deck.gl"; +import { OrthographicView, PolygonLayer } from "deck.gl"; import { useAtom, useAtomValue } from "jotai"; import * as React from "react"; import { useViewState } from "../hooks"; import { useAxisNavigation } from "../hooks/useAxisNavigation"; -import { layerAtoms, viewportAtom } from "../state"; +import { layerAtoms, currentZInfoAtom, roiDrawStateAtom, viewportAtom } from "../state"; import { fitImageToViewport, getLayerSize, resolveLoaderFromLayerProps } from "../utils"; import type { DeckGLRef, OrthographicViewState, PickingInfo } from "deck.gl"; @@ -19,6 +19,22 @@ export default function Viewer() { const layers = useAtomValue(layerAtoms); const firstLayer = layers[0] as VizarrLayer; const axisNavigationSnackbar = useAxisNavigation(deckRef, viewport); + // ---- ROI draw-on-image support ---- + // Read the shared draw-mode atom so we know whether to intercept clicks. + const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); + const isDrawing = roiDrawState !== null; + + // Current Z-axis info (may be null if there's no Z axis). + const zInfo = useAtomValue(currentZInfoAtom); + + // Track the current mouse position in image coordinates for the preview rectangle. + const [roiMousePos, setRoiMousePos] = React.useState<[number, number] | null>(null); + + // The first corner (if placed) — extracted for convenience. + const roiCorner1 = + roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState + ? roiDrawState.corner1 + : null; const resetViewState = React.useCallback( (layer: VizarrLayer) => { @@ -135,11 +151,90 @@ export default function Viewer() { }; }, [layers]); + /** + * Handle clicks on the deck.gl canvas. + * + * When draw mode is active (`roiDrawState !== null`), clicks are + * intercepted to place ROI corners instead of doing the default + * pick / tooltip behaviour. + * + * `info.coordinate` is the [x, y] position in **image space** (world + * coordinates) — exactly what we need for the ROI bounding box. + */ + const handleClick = React.useCallback( + (info: PickingInfo) => { + if (!isDrawing || !info.coordinate) return; + + const [x, y] = info.coordinate; + + if (roiDrawState === "waiting-first") { + // First click → record corner 1 + current Z, wait for corner 2 + const z1 = zInfo?.zValue ?? 0; + setRoiDrawState({ corner1: [Math.round(x), Math.round(y)], z1 }); + } else if (roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState) { + // Second click → record corner 2 + current Z. + // We use a custom event on window so RoiSelector can pick it up. + const corner2: [number, number] = [Math.round(x), Math.round(y)]; + const z2 = zInfo?.zValue ?? 0; + window.dispatchEvent( + new CustomEvent("vizarr-roi-drawn", { + detail: { corner1: roiDrawState.corner1, corner2, z1: roiDrawState.z1, z2 }, + }), + ); + setRoiDrawState(null); + } + }, + [isDrawing, roiDrawState, setRoiDrawState, zInfo], + ); + + // Track mouse movement in image coordinates while waiting for the second corner. + const handleHover = React.useCallback( + (info: PickingInfo) => { + if (roiCorner1 && info.coordinate) { + setRoiMousePos([info.coordinate[0], info.coordinate[1]]); + } else { + setRoiMousePos(null); + } + }, + [roiCorner1], + ); + + // Build a preview rectangle layer when corner1 is placed and cursor is moving. + const roiPreviewLayer = React.useMemo(() => { + if (!roiCorner1 || !roiMousePos) return null; + const [x1, y1] = roiCorner1; + const [x2, y2] = roiMousePos; + return new PolygonLayer({ + id: "roi-preview", + data: [ + { + polygon: [ + [x1, y1], + [x2, y1], + [x2, y2], + [x1, y2], + ], + }, + ], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: [255, 255, 255, 40], + getLineColor: [255, 200, 0, 200], + getLineWidth: 2, + lineWidthUnits: "pixels", + stroked: true, + filled: true, + pickable: false, + }); + }, [roiCorner1, roiMousePos]); + + // Change the cursor to crosshair while draw mode is active + const getCursor = React.useCallback(() => (isDrawing ? "crosshair" : "grab"), [isDrawing]); + return ( <> @@ -149,6 +244,9 @@ export default function Viewer() { views={[new OrthographicView({ id: "ortho", controller: true, near, far })]} glOptions={glOptions} getTooltip={getTooltip} + onClick={handleClick} + onHover={handleHover} + getCursor={getCursor} onDeviceInitialized={() => setViewport(deckRef.current?.deck || null)} /> {axisNavigationSnackbar} diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index 19743551..956c6043 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -24,9 +24,10 @@ export interface VizarrViewerProps { sources?: string[]; viewState?: ViewState; onViewStateChange?: (viewState: ViewState) => void; + children?: React.ReactNode; } -function VizarrViewerComponent({ sources = [], viewState: initialViewState, onViewStateChange }: VizarrViewerProps) { +function VizarrViewerComponent({ sources = [], viewState: initialViewState, onViewStateChange, children }: VizarrViewerProps) { const setSourceInfo = useSetAtom(sourceInfoAtom); const setViewStateAtom = useSetAtom(viewStateAtom); const sourceError = useAtomValue(sourceErrorAtom); @@ -99,6 +100,7 @@ function VizarrViewerComponent({ sources = [], viewState: initialViewState, onVi + {children} )} {sourceError !== null && ( @@ -155,11 +157,11 @@ function VizarrViewerComponent({ sources = [], viewState: initialViewState, onVi ); } -export default function VizarrViewer(props: VizarrViewerProps) { +export default function VizarrViewer({ children, ...props }: VizarrViewerProps) { return ( - + {children} ); diff --git a/viewer/src/index.tsx b/viewer/src/index.tsx index 5fe6d55e..663a5b72 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -8,3 +8,8 @@ export { createViewer } from "./api"; export type { VizarrViewer } from "./api"; export type { ViewState, ImageLayerConfig } from "./state"; + +// ROI-related atoms & hooks — used by the @biongff/roi-selector plugin +export { roiDrawStateAtom, currentZInfoAtom, viewportAtom } from "./state"; +export type { RoiDrawState } from "./state"; +export { useViewState, ViewStateContext } from "./hooks"; diff --git a/viewer/src/state.ts b/viewer/src/state.ts index dab72dd2..2282315a 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -115,6 +115,34 @@ export const viewStateAtom = atom(null); export const sourceErrorAtom = atom(null); export const sourceWarningAtom = atom([]); +/** + * Shared state for the "draw ROI on image" feature. + * + * State machine: + * null → draw mode is OFF + * "waiting-first" → draw mode ON, waiting for the first click + * { corner1, z1 } → first corner placed, waiting for second click + */ +export type RoiDrawState = null | "waiting-first" | { corner1: [number, number]; z1: number }; +export const roiDrawStateAtom = atom(null); + +/** + * Derived atom that exposes the current Z-axis selection and metadata + * from the first loaded source. Returns null when there is no source + * or the data has no Z axis. + */ +export const currentZInfoAtom = atom((get) => { + const sources = get(sourceInfoAtom); + if (sources.length === 0) return null; + const source = sources[0]; + const zAxisIndex = source.axis_labels.indexOf("z"); + if (zAxisIndex === -1) return null; + const layerState = get(layerFamilyAtom(source)); + const zValue = layerState.layerProps.selections[0]?.[zAxisIndex] ?? 0; + const zMax = source.loader[0].shape[zAxisIndex] - 1; + return { zValue, zMax }; +}); + export interface Redirect { url: string; message: string; From 65a9adee193ae42450652c506dd5918629f47a90 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 13:36:17 +0100 Subject: [PATCH 02/32] feat!: implemented multiple ROI selection and handling. UI cleaned and simplified. --- roi-selector/src/RoiSelector.tsx | 626 ++++++++++++++++++++++--------- viewer/src/components/Viewer.tsx | 89 ++++- viewer/src/index.tsx | 4 +- viewer/src/state.ts | 55 +++ 4 files changed, 574 insertions(+), 200 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index f6d53020..9d83216a 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -1,8 +1,9 @@ -import { ContentCopy, CropFree, HighlightAlt } from "@mui/icons-material"; +import { ContentCopy, CropFree, Delete, Edit, ExpandMore, HighlightAlt, VisibilityOff, MyLocation, SelectAll } from "@mui/icons-material"; import { Box, Button, Collapse, + Divider, Grid, IconButton, Snackbar, @@ -10,10 +11,20 @@ import { Tooltip, Typography, } from "@mui/material"; -import { useAtom, useAtomValue } from "jotai"; -import React, { useEffect, useState } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import React, { useEffect, useRef, useState } from "react"; -import { useViewState, currentZInfoAtom, roiDrawStateAtom, viewportAtom } from "@biongff/vizarr"; +import { + useViewState, + currentZInfoAtom, + roiDrawStateAtom, + viewportAtom, + savedRoisAtom, + pendingRoiAtom, + ROI_COLORS, + setZSliceAtom, + type SavedRoi, +} from "@biongff/vizarr"; /** * RoiSelector — a collapsible panel that lets you: @@ -40,6 +51,8 @@ import { useViewState, currentZInfoAtom, roiDrawStateAtom, viewportAtom } from " function RoiSelector() { // -------- local UI state -------- const [open, setOpen] = useState(false); + const [roiMenuOpen, setRoiMenuOpen] = useState(false); + const [editingRoiId, setEditingRoiId] = useState(null); // The four text fields (kept as strings so the user can type freely): const [x1, setX1] = useState(""); @@ -64,21 +77,75 @@ function RoiSelector() { const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); const isDrawing = roiDrawState !== null; - // Listen for the custom event that Viewer dispatches after the second click. - // This is how the drawn coordinates flow back into our text fields. + // -------- multi-ROI state -------- + const [savedRois, setSavedRois] = useAtom(savedRoisAtom); + const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); + + // Track whether we are the ones updating pendingRoi (to avoid re-sync loop). + const internalUpdate = useRef(false); + + // Fill text fields when a pending ROI is set externally (after second click in Viewer). useEffect(() => { - const handler = (e: Event) => { - const { corner1, corner2, z1: eventZ1, z2: eventZ2 } = (e as CustomEvent).detail; - setX1(String(corner1[0])); - setY1(String(corner1[1])); - setX2(String(corner2[0])); - setY2(String(corner2[1])); - setZ1(String(Math.min(eventZ1, eventZ2))); - setZ2(String(Math.max(eventZ1, eventZ2))); - }; - window.addEventListener("vizarr-roi-drawn", handler); - return () => window.removeEventListener("vizarr-roi-drawn", handler); - }, []); + if (internalUpdate.current) { + internalUpdate.current = false; + return; + } + if (pendingRoi) { + const { corner1, corner2, z1: pz1, z2: pz2 } = pendingRoi; + setX1(String(Math.min(corner1[0], corner2[0]))); + setY1(String(Math.min(corner1[1], corner2[1]))); + setX2(String(Math.max(corner1[0], corner2[0]))); + setY2(String(Math.max(corner1[1], corner2[1]))); + setZ1(String(Math.min(pz1, pz2))); + setZ2(String(Math.max(pz1, pz2))); + } + }, [pendingRoi]); + + /** Sync text field changes back to the pendingRoi atom so the overlay updates live. */ + const syncFieldsToPending = ( + nx1: string, ny1: string, nx2: string, ny2: string, nz1: string, nz2: string, + ) => { + // When editing a saved ROI, update it in-place for live overlay feedback. + if (editingRoiId) { + const px1 = Number(nx1); + const py1 = Number(ny1); + const px2 = Number(nx2); + const py2 = Number(ny2); + if ([px1, py1, px2, py2].some(Number.isNaN)) return; + setSavedRois(savedRois.map((r) => { + if (r.id !== editingRoiId) return r; + return { + ...r, + corner1: [Math.min(px1, px2), Math.min(py1, py2)], + corner2: [Math.max(px1, px2), Math.max(py1, py2)], + z1: nz1 !== "" ? Number(nz1) : r.z1, + z2: nz2 !== "" ? Number(nz2) : r.z2, + }; + })); + return; + } + if (!pendingRoi) return; + const px1 = Number(nx1); + const py1 = Number(ny1); + const px2 = Number(nx2); + const py2 = Number(ny2); + if ([px1, py1, px2, py2].some(Number.isNaN)) return; + internalUpdate.current = true; + setPendingRoi({ + corner1: [Math.min(px1, px2), Math.min(py1, py2)], + corner2: [Math.max(px1, px2), Math.max(py1, py2)], + z1: nz1 !== "" ? Number(nz1) : pendingRoi.z1, + z2: nz2 !== "" ? Number(nz2) : pendingRoi.z2, + }); + }; + + // Field change helpers — update local state AND sync to pending overlay. + const onX1Change = (v: string) => { setX1(v); syncFieldsToPending(v, y1, x2, y2, z1, z2); }; + const onY1Change = (v: string) => { setY1(v); syncFieldsToPending(x1, v, x2, y2, z1, z2); }; + const onX2Change = (v: string) => { setX2(v); syncFieldsToPending(x1, y1, v, y2, z1, z2); }; + const onY2Change = (v: string) => { setY2(v); syncFieldsToPending(x1, y1, x2, v, z1, z2); }; + const onZ1Change = (v: string) => { setZ1(v); syncFieldsToPending(x1, y1, x2, y2, v, z2); }; + const onZ2Change = (v: string) => { setZ2(v); syncFieldsToPending(x1, y1, x2, y2, z1, v); }; /** * Toggle draw mode on/off. @@ -93,122 +160,162 @@ function RoiSelector() { } }; - // -------- shared viewer state -------- - // `viewState` holds the current zoom / target the viewer is showing. - // `setViewState` lets us drive the camera programmatically. - const [viewState, setViewState] = useViewState(); - - // `viewport` gives us the pixel dimensions of the deck.gl canvas so we know - // how large the browser window is — needed to compute the zoom level. - const viewport = useAtomValue(viewportAtom); - - // -------- handlers -------- - - /** - * Called when the user clicks "Go to ROI". - * Parses the four coordinate fields, computes the target & zoom, and - * pushes the new viewState into the Jotai atom (which re-renders ). - */ - const handleGoToRoi = () => { + /** Save the pending ROI to the saved list, using the (possibly adjusted) text field values. */ + const handleSaveRoi = () => { + if (!pendingRoi) return; const nx1 = Number(x1); const ny1 = Number(y1); const nx2 = Number(x2); const ny2 = Number(y2); - - // Guard: all four must be valid numbers if ([nx1, ny1, nx2, ny2].some(Number.isNaN)) return; - // Guard: the ROI must have non-zero area - if (nx1 === nx2 || ny1 === ny2) return; - // Guard: need viewport dimensions to compute zoom - if (!viewport) return; + const color = ROI_COLORS[savedRois.length % ROI_COLORS.length]; + const nz1 = z1 !== "" ? Number(z1) : pendingRoi.z1; + const nz2 = z2 !== "" ? Number(z2) : pendingRoi.z2; + setSavedRois([ + ...savedRois, + { + id: Math.random().toString(36).slice(2), + corner1: [Math.min(nx1, nx2), Math.min(ny1, ny2)], + corner2: [Math.max(nx1, nx2), Math.max(ny1, ny2)], + z1: Math.min(nz1, nz2), + z2: Math.max(nz1, nz2), + color, + visible: true, + }, + ]); + setPendingRoi(null); + }; - // Ensure min/max are correct regardless of input order - const minX = Math.min(nx1, nx2); - const maxX = Math.max(nx1, nx2); - const minY = Math.min(ny1, ny2); - const maxY = Math.max(ny1, ny2); + /** Discard the pending ROI without saving. */ + const handleDiscardRoi = () => { + setPendingRoi(null); + }; + + /** Delete a saved ROI by id. */ + const handleDeleteRoi = (id: string) => { + setSavedRois(savedRois.filter((r) => r.id !== id)); + if (editingRoiId === id) setEditingRoiId(null); + }; + + /** Toggle visibility of a saved ROI. */ + const handleToggleVisibility = (id: string) => { + setSavedRois(savedRois.map((r) => (r.id === id ? { ...r, visible: !r.visible } : r))); + }; + + /** Stash the original values when entering edit mode so we can restore on cancel. */ + const editOriginal = useRef(null); + + /** Enter edit mode for a saved ROI: populate fields and track the id. */ + const handleEditRoi = (roi: SavedRoi) => { + // Cancel any in-progress drawing or pending ROI + if (pendingRoi) setPendingRoi(null); + if (isDrawing) setRoiDrawState(null); + editOriginal.current = { ...roi }; + setEditingRoiId(roi.id); + const minX = Math.min(roi.corner1[0], roi.corner2[0]); + const minY = Math.min(roi.corner1[1], roi.corner2[1]); + const maxX = Math.max(roi.corner1[0], roi.corner2[0]); + const maxY = Math.max(roi.corner1[1], roi.corner2[1]); + setX1(String(minX)); + setY1(String(minY)); + setX2(String(maxX)); + setY2(String(maxY)); + setZ1(String(Math.min(roi.z1, roi.z2))); + setZ2(String(Math.max(roi.z1, roi.z2))); + }; + + /** Finish editing: commit current field values (already live-synced) and exit edit mode. */ + const handleUpdateRoi = () => { + editOriginal.current = null; + setEditingRoiId(null); + }; + + /** Cancel editing: restore the saved ROI to its original values. */ + const handleCancelEdit = () => { + if (editOriginal.current && editingRoiId) { + const orig = editOriginal.current; + setSavedRois(savedRois.map((r) => (r.id === editingRoiId ? orig : r))); + } + editOriginal.current = null; + setEditingRoiId(null); + }; + + // Write-only atom to change the Z slice for all sources. + const setZSlice = useSetAtom(setZSliceAtom); + /** Navigate the viewer to a specific saved ROI (XY + Z) and ensure visibility. */ + const handleGoToSavedRoi = (roi: SavedRoi) => { + if (!viewport) return; + const minX = Math.min(roi.corner1[0], roi.corner2[0]); + const maxX = Math.max(roi.corner1[0], roi.corner2[0]); + const minY = Math.min(roi.corner1[1], roi.corner2[1]); + const maxY = Math.max(roi.corner1[1], roi.corner2[1]); const roiWidth = maxX - minX; const roiHeight = maxY - minY; - - // A small padding (in screen-pixels) so the ROI doesn't touch the edges: + if (roiWidth === 0 || roiHeight === 0) return; const padding = 40; const availableWidth = viewport.width - 2 * padding; const availableHeight = viewport.height - 2 * padding; - - // The zoom level that fits the ROI inside the viewport: const zoom = Math.log2(Math.min(availableWidth / roiWidth, availableHeight / roiHeight)); - setViewState({ zoom, target: [(minX + maxX) / 2, (minY + maxY) / 2], width: viewport.width, height: viewport.height, }); - }; - - /** - * Computes the bounding box of the currently visible region and returns it - * as { x1, y1, x2, y2 } in image coordinates. - * - * The math is the inverse of the zoom formula: - * scale = 2^zoom - * half-visible width = (viewportWidth / scale) / 2 - * half-visible height = (viewportHeight / scale) / 2 - * top-left = target - half-visible - * bottom-right = target + half-visible - */ - const getCurrentRoi = () => { - if (!viewState || !viewport) return null; - const scale = 2 ** viewState.zoom; - const halfW = viewport.width / scale / 2; - const halfH = viewport.height / scale / 2; - return { - x1: Math.round(viewState.target[0] - halfW), - y1: Math.round(viewState.target[1] - halfH), - x2: Math.round(viewState.target[0] + halfW), - y2: Math.round(viewState.target[1] + halfH), - }; - }; - - /** - * Fills the text fields with the coordinates of the current view. - * Handy for tweaking a region you navigated to manually. - */ - const handleFillFromView = () => { - const roi = getCurrentRoi(); - if (!roi) return; - setX1(String(roi.x1)); - setY1(String(roi.y1)); - setX2(String(roi.x2)); - setY2(String(roi.y2)); + // Navigate to the ROI's Z plane (use the start of its range) if (hasZAxis) { - const currentZ = String(zInfo.zValue); - setZ1(currentZ); - setZ2(currentZ); + setZSlice(Math.min(roi.z1, roi.z2)); + } + // Ensure the ROI is visible + if (!roi.visible) { + setSavedRois(savedRois.map((r) => (r.id === roi.id ? { ...r, visible: true } : r))); } }; - /** - * Copies the ROI coordinates (including Z if available) to the clipboard - * as a JSON string. - */ - const handleCopyToClipboard = () => { - const roi = getCurrentRoi(); - if (!roi) return; - const payload: Record = { ...roi }; + /** Copy a single ROI's coordinates to clipboard. */ + const handleCopySingleRoi = (roi: SavedRoi) => { + const minX = Math.min(roi.corner1[0], roi.corner2[0]); + const minY = Math.min(roi.corner1[1], roi.corner2[1]); + const maxX = Math.max(roi.corner1[0], roi.corner2[0]); + const maxY = Math.max(roi.corner1[1], roi.corner2[1]); + const payload: Record = { x1: minX, y1: minY, x2: maxX, y2: maxY }; if (hasZAxis) { - const nz1 = z1 !== "" ? Number(z1) : zInfo.zValue; - const nz2 = z2 !== "" ? Number(z2) : zInfo.zValue; - payload.z1 = Math.min(nz1, nz2); - payload.z2 = Math.max(nz1, nz2); + payload.z1 = Math.min(roi.z1, roi.z2); + payload.z2 = Math.max(roi.z1, roi.z2); } - const text = JSON.stringify(payload); - navigator.clipboard.writeText(text).then(() => { - setSnackOpen(true); + navigator.clipboard.writeText(JSON.stringify(payload)).then(() => setSnackOpen(true)); + }; + + /** Copy all saved ROIs to clipboard as a JSON array. */ + const handleCopyAllRois = () => { + const arr = savedRois.map((roi) => { + const minX = Math.min(roi.corner1[0], roi.corner2[0]); + const minY = Math.min(roi.corner1[1], roi.corner2[1]); + const maxX = Math.max(roi.corner1[0], roi.corner2[0]); + const maxY = Math.max(roi.corner1[1], roi.corner2[1]); + const payload: Record = { x1: minX, y1: minY, x2: maxX, y2: maxY }; + if (hasZAxis) { + payload.z1 = Math.min(roi.z1, roi.z2); + payload.z2 = Math.max(roi.z1, roi.z2); + } + return payload; }); + navigator.clipboard.writeText(JSON.stringify(arr, null, 2)).then(() => setSnackOpen(true)); }; + // -------- shared viewer state -------- + // `setViewState` lets us drive the camera programmatically. + const [, setViewState] = useViewState(); + + // `viewport` gives us the pixel dimensions of the deck.gl canvas so we know + // how large the browser window is — needed to compute the zoom level. + const viewport = useAtomValue(viewportAtom); + + // -------- handlers -------- + + + // -------- render -------- return ( @@ -241,6 +348,9 @@ function RoiSelector() { {/* Collapsible panel */} + {/* ---- Coordinate fields (shown when there is a pending ROI or editing a saved ROI) ---- */} + {(pendingRoi || editingRoiId) && ( + <> {/* ---- Top-left coordinate ---- */} Top-left (x₁, y₁) @@ -252,7 +362,7 @@ function RoiSelector() { size="small" type="number" value={x1} - onChange={(e) => setX1(e.target.value)} + onChange={(e) => onX1Change(e.target.value)} fullWidth slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} /> @@ -263,7 +373,7 @@ function RoiSelector() { size="small" type="number" value={y1} - onChange={(e) => setY1(e.target.value)} + onChange={(e) => onY1Change(e.target.value)} fullWidth slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} /> @@ -281,7 +391,7 @@ function RoiSelector() { size="small" type="number" value={x2} - onChange={(e) => setX2(e.target.value)} + onChange={(e) => onX2Change(e.target.value)} fullWidth slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} /> @@ -292,7 +402,7 @@ function RoiSelector() { size="small" type="number" value={y2} - onChange={(e) => setY2(e.target.value)} + onChange={(e) => onY2Change(e.target.value)} fullWidth slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} /> @@ -312,7 +422,7 @@ function RoiSelector() { size="small" type="number" value={z1} - onChange={(e) => setZ1(e.target.value)} + onChange={(e) => onZ1Change(e.target.value)} fullWidth slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } }, @@ -326,7 +436,7 @@ function RoiSelector() { size="small" type="number" value={z2} - onChange={(e) => setZ2(e.target.value)} + onChange={(e) => onZ2Change(e.target.value)} fullWidth slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } }, @@ -337,85 +447,233 @@ function RoiSelector() { )} + + )} - {/* ---- Action buttons ---- */} - - - + {/* ---- Draw / Save / Discard / Update / Cancel buttons ---- */} + {editingRoiId ? ( + + + + + + + - - + ) : pendingRoi ? ( + + + + + + + - - - {/* ---- Draw on image button ---- */} - - - {/* ---- Current view info + copy button ---- */} - {viewState && viewport && ( - } + color={isDrawing ? "warning" : "primary"} + sx={{ textTransform: "none", fontSize: 11, mt: 0.5, mb: 0.5 }} > - - - Current view + {isDrawing + ? roiDrawState === "waiting-first" + ? "Click corner 1…" + : "Click corner 2…" + : "Draw on image"} + + )} + + {/* ---- Saved ROIs (collapsible menu) ---- */} + {savedRois.length > 0 && ( + <> + + setRoiMenuOpen((prev) => !prev)} + sx={{ + cursor: "pointer", + display: "flex", + alignItems: "center", + "&:hover": { opacity: 0.8 }, + }} + > + + + Saved ROIs ({savedRois.length}) - {(() => { - const roi = getCurrentRoi(); - if (!roi) return null; - const zText = hasZAxis && z1 !== "" && z2 !== "" ? ` z:[${z1}–${z2}]` : ""; - return ( - - ({roi.x1}, {roi.y1}) → ({roi.x2}, {roi.y2}){zText} - - ); - })()} - - - - - - + + + {savedRois.map((roi) => { + const minX = Math.min(roi.corner1[0], roi.corner2[0]); + const minY = Math.min(roi.corner1[1], roi.corner2[1]); + const maxX = Math.max(roi.corner1[0], roi.corner2[0]); + const maxY = Math.max(roi.corner1[1], roi.corner2[1]); + const roiZ1 = Math.min(roi.z1, roi.z2); + const roiZ2 = Math.max(roi.z1, roi.z2); + return ( + + {/* Color dot – click to toggle visibility */} + + handleToggleVisibility(roi.id)} + sx={{ p: 0, mr: 0.5, flexShrink: 0, width: 16, height: 16, minWidth: 0 }} + > + {roi.visible ? ( + + ) : ( + + )} + + + {/* Coordinates + Z info */} + + + ({minX}, {minY}) → ({maxX}, {maxY}) + + {hasZAxis && ( + + z: {roiZ1 === roiZ2 ? roiZ1 : `${roiZ1}–${roiZ2}`} + + )} + + {/* Action icons */} + + handleGoToSavedRoi(roi)} + sx={{ color: "grey.400", p: 0.25 }} + > + + + + + handleCopySingleRoi(roi)} + sx={{ color: "grey.400", p: 0.25 }} + > + + + + + + handleEditRoi(roi)} + sx={{ color: editingRoiId === roi.id ? "primary.main" : "grey.400", p: 0.25 }} + > + + + + + handleDeleteRoi(roi.id)} + sx={{ color: "grey.500", p: 0.25 }} + > + + + + + ); + })} + {/* Copy All button */} + + + + )} + + diff --git a/viewer/src/components/Viewer.tsx b/viewer/src/components/Viewer.tsx index ac306854..40e26fcf 100644 --- a/viewer/src/components/Viewer.tsx +++ b/viewer/src/components/Viewer.tsx @@ -5,7 +5,7 @@ import { useAtom, useAtomValue } from "jotai"; import * as React from "react"; import { useViewState } from "../hooks"; import { useAxisNavigation } from "../hooks/useAxisNavigation"; -import { layerAtoms, currentZInfoAtom, roiDrawStateAtom, viewportAtom } from "../state"; +import { layerAtoms, currentZInfoAtom, roiDrawStateAtom, viewportAtom, savedRoisAtom, pendingRoiAtom, ROI_COLORS } from "../state"; import { fitImageToViewport, getLayerSize, resolveLoaderFromLayerProps } from "../utils"; import type { DeckGLRef, OrthographicViewState, PickingInfo } from "deck.gl"; @@ -36,6 +36,11 @@ export default function Viewer() { ? roiDrawState.corner1 : null; + // ---- Multi-ROI state ---- + const savedRois = useAtomValue(savedRoisAtom); + const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); + const nextRoiColor = ROI_COLORS[savedRois.length % ROI_COLORS.length]; + const resetViewState = React.useCallback( (layer: VizarrLayer) => { const { deck } = deckRef.current || {}; @@ -152,7 +157,7 @@ export default function Viewer() { }, [layers]); /** - * Handle clicks on the deck.gl canvas. + * Handle clicks on the deck.gl canvas. -> Needed for ROI drawing in RoiSelector * * When draw mode is active (`roiDrawState !== null`), clicks are * intercepted to place ROI corners instead of doing the default @@ -172,19 +177,19 @@ export default function Viewer() { const z1 = zInfo?.zValue ?? 0; setRoiDrawState({ corner1: [Math.round(x), Math.round(y)], z1 }); } else if (roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState) { - // Second click → record corner 2 + current Z. - // We use a custom event on window so RoiSelector can pick it up. + // Second click → set as pending ROI for save/discard in RoiSelector. const corner2: [number, number] = [Math.round(x), Math.round(y)]; const z2 = zInfo?.zValue ?? 0; - window.dispatchEvent( - new CustomEvent("vizarr-roi-drawn", { - detail: { corner1: roiDrawState.corner1, corner2, z1: roiDrawState.z1, z2 }, - }), - ); + setPendingRoi({ + corner1: roiDrawState.corner1, + corner2, + z1: roiDrawState.z1, + z2, + }); setRoiDrawState(null); } }, - [isDrawing, roiDrawState, setRoiDrawState, zInfo], + [isDrawing, roiDrawState, setRoiDrawState, setPendingRoi, zInfo], ); // Track mouse movement in image coordinates while waiting for the second corner. @@ -217,15 +222,71 @@ export default function Viewer() { }, ], getPolygon: (d: { polygon: [number, number][] }) => d.polygon, - getFillColor: [255, 255, 255, 40], - getLineColor: [255, 200, 0, 200], + getFillColor: [...nextRoiColor, 40] as [number, number, number, number], + getLineColor: [...nextRoiColor, 200] as [number, number, number, number], getLineWidth: 2, lineWidthUnits: "pixels", stroked: true, filled: true, pickable: false, }); - }, [roiCorner1, roiMousePos]); + }, [roiCorner1, roiMousePos, nextRoiColor]); + + // Build polygon overlay layers for saved ROIs and the pending (unsaved) ROI. + // Only show saved ROIs that are visible AND whose z-range includes the current z slice. + const currentZ = zInfo?.zValue ?? null; + const roiOverlayLayers = React.useMemo(() => { + const overlays = []; + + // Saved ROIs — filtered by visibility and current Z plane + for (const roi of savedRois) { + if (!roi.visible) continue; + // If there's a Z axis, only show ROI when current Z is within its z range + if (currentZ !== null) { + const zMin = Math.min(roi.z1, roi.z2); + const zMax = Math.max(roi.z1, roi.z2); + if (currentZ < zMin || currentZ > zMax) continue; + } + const [ax, ay] = roi.corner1; + const [bx, by] = roi.corner2; + overlays.push( + new PolygonLayer({ + id: `roi-saved-${roi.id}`, + data: [{ polygon: [[ax, ay], [bx, ay], [bx, by], [ax, by]] }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: [...roi.color, 40] as [number, number, number, number], + getLineColor: [...roi.color, 200] as [number, number, number, number], + getLineWidth: 2, + lineWidthUnits: "pixels" as const, + stroked: true, + filled: true, + pickable: false, + }), + ); + } + + // Pending ROI (drawn but not yet saved/discarded) + if (pendingRoi) { + const [ax, ay] = pendingRoi.corner1; + const [bx, by] = pendingRoi.corner2; + overlays.push( + new PolygonLayer({ + id: "roi-pending", + data: [{ polygon: [[ax, ay], [bx, ay], [bx, by], [ax, by]] }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: [...nextRoiColor, 60] as [number, number, number, number], + getLineColor: [...nextRoiColor, 220] as [number, number, number, number], + getLineWidth: 2, + lineWidthUnits: "pixels" as const, + stroked: true, + filled: true, + pickable: false, + }), + ); + } + + return overlays; + }, [savedRois, pendingRoi, nextRoiColor, currentZ]); // Change the cursor to crosshair while draw mode is active const getCursor = React.useCallback(() => (isDrawing ? "crosshair" : "grab"), [isDrawing]); @@ -234,7 +295,7 @@ export default function Viewer() { <> diff --git a/viewer/src/index.tsx b/viewer/src/index.tsx index 663a5b72..f84e557a 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -10,6 +10,6 @@ export type { VizarrViewer } from "./api"; export type { ViewState, ImageLayerConfig } from "./state"; // ROI-related atoms & hooks — used by the @biongff/roi-selector plugin -export { roiDrawStateAtom, currentZInfoAtom, viewportAtom } from "./state"; -export type { RoiDrawState } from "./state"; +export { roiDrawStateAtom, currentZInfoAtom, viewportAtom, savedRoisAtom, pendingRoiAtom, ROI_COLORS, setZSliceAtom } from "./state"; +export type { RoiDrawState, SavedRoi, PendingRoi } from "./state"; export { useViewState, ViewStateContext } from "./hooks"; diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 2282315a..e9dadda5 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -126,6 +126,40 @@ export const sourceWarningAtom = atom([]); export type RoiDrawState = null | "waiting-first" | { corner1: [number, number]; z1: number }; export const roiDrawStateAtom = atom(null); +/** A saved ROI with its assigned overlay color. */ +export interface SavedRoi { + id: string; + corner1: [number, number]; + corner2: [number, number]; + z1: number; + z2: number; + color: [number, number, number]; + visible: boolean; +} + +/** A ROI that has been drawn but not yet saved or discarded. */ +export interface PendingRoi { + corner1: [number, number]; + corner2: [number, number]; + z1: number; + z2: number; +} + +export const savedRoisAtom = atom([]); +export const pendingRoiAtom = atom(null); + +/** Color palette (RGB) cycled through for multi-ROI overlays. */ +export const ROI_COLORS: [number, number, number][] = [ + [255, 100, 100], // red + [100, 180, 255], // blue + [100, 220, 100], // green + [255, 200, 50], // yellow + [200, 100, 255], // purple + [255, 150, 50], // orange + [50, 220, 200], // teal + [255, 100, 200], // pink +]; + /** * Derived atom that exposes the current Z-axis selection and metadata * from the first loaded source. Returns null when there is no source @@ -143,6 +177,27 @@ export const currentZInfoAtom = atom((get) => { return { zValue, zMax }; }); +/** + * Write-only atom that sets the Z-axis slice for all loaded sources. + * Pass a z index number and it will update every source's selection. + */ +export const setZSliceAtom = atom(null, (get, set, zValue: number) => { + const sources = get(sourceInfoAtom); + for (const source of sources) { + const zAxisIndex = source.axis_labels.indexOf("z"); + if (zAxisIndex === -1) continue; + const layerStateAtom = layerFamilyAtom(source); + set(layerStateAtom, (prev) => { + const selections = prev.layerProps.selections.map((ch) => { + const newCh = [...ch]; + newCh[zAxisIndex] = zValue; + return newCh; + }); + return { ...prev, layerProps: { ...prev.layerProps, selections } }; + }); + } +}); + export interface Redirect { url: string; message: string; From a8df79b24c06c99d3b193185dc92731e2f677646 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 13:36:17 +0100 Subject: [PATCH 03/32] refactor: make roi-selector an optional plugin from pnpm --- pnpm-lock.yaml | 7 ++-- sites/app/package.json | 4 ++- sites/app/src/App.tsx | 62 ++++++++++++++++++++++++++++----- sites/app/src/roi-selector.d.ts | 8 +++++ sites/app/vite.config.js | 34 +++++++++++++++++- 5 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 sites/app/src/roi-selector.d.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd70167a..5f147452 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,9 +113,6 @@ importers: sites/app: dependencies: - '@biongff/roi-selector': - specifier: workspace:* - version: link:../../roi-selector '@biongff/vizarr': specifier: workspace:* version: link:../../viewer @@ -140,6 +137,10 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + optionalDependencies: + '@biongff/roi-selector': + specifier: workspace:* + version: link:../../roi-selector devDependencies: '@types/node': specifier: ^24.3.0 diff --git a/sites/app/package.json b/sites/app/package.json index a4773de4..6305ad6f 100644 --- a/sites/app/package.json +++ b/sites/app/package.json @@ -8,8 +8,10 @@ "preview": "vite preview", "check": "tsc" }, + "optionalDependencies": { + "@biongff/roi-selector": "workspace:*" + }, "dependencies": { - "@biongff/roi-selector": "workspace:*", "@biongff/vizarr": "workspace:*", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/sites/app/src/App.tsx b/sites/app/src/App.tsx index dd16136a..2195a6a1 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -1,8 +1,28 @@ -import { RoiSelector } from "@biongff/roi-selector"; import { type ViewState, Vizarr } from "@biongff/vizarr"; import debounce from "just-debounce-it"; import * as React from "react"; +/** + * Lazy load the optional ROI selector plugin. + * If `@biongff/roi-selector` is not installed (or substituted by the + * optionalDeps Vite plugin), this resolves to `null` and nothing renders. + */ +const roiPromise: Promise<{ default: React.ComponentType } | null> = import("@biongff/roi-selector") + .then((mod) => + typeof mod.RoiSelector === "function" + ? { default: mod.RoiSelector } + : null, + ) + .catch(() => null); + +/** True once we know the plugin is available (resolved at module level). */ +let roiAvailable = false; +roiPromise.then((m) => { roiAvailable = m !== null; }); + +const LazyRoiSelector = React.lazy(() => + roiPromise.then((m) => m ?? { default: (() => null) as unknown as React.FC }), +); + function parseViewStateFromUrl(): ViewState | undefined { const url = new URL(window.location.href); const viewStateString = url.searchParams.get("viewState"); @@ -21,22 +41,44 @@ function parseViewStateFromUrl(): ViewState | undefined { export default function App() { const urlString = window.location.href; + // Re-render once we know whether the ROI plugin is available (async check). + const [roiReady, setRoiReady] = React.useState(roiAvailable); + React.useEffect(() => { + if (!roiReady) { + roiPromise.then((m) => { + roiAvailable = m !== null; + setRoiReady(true); + }); + } + }, [roiReady]); + const { sources, viewState, enableRoi } = React.useMemo(() => { const url = new URL(urlString); const { searchParams } = url; - // Ensure `roi` param is always visible in the URL (default: "0") - if (!searchParams.has("roi")) { - searchParams.set("roi", "0"); - window.history.replaceState(window.history.state, "", decodeURIComponent(url.href)); + // Don't touch the URL until we know whether the plugin is available. + if (roiReady) { + if (roiAvailable) { + // Plugin installed — ensure `roi` param is visible (default: "0") + if (!searchParams.has("roi")) { + searchParams.set("roi", "0"); + window.history.replaceState(window.history.state, "", decodeURIComponent(url.href)); + } + } else { + // Plugin not installed — remove stale roi param + if (searchParams.has("roi")) { + searchParams.delete("roi"); + window.history.replaceState(window.history.state, "", decodeURIComponent(url.href)); + } + } } return { sources: searchParams.getAll("source"), viewState: parseViewStateFromUrl(), - enableRoi: searchParams.get("roi") === "1", + enableRoi: roiAvailable && searchParams.get("roi") === "1", }; - }, [urlString]); + }, [urlString, roiReady]); // Debounced viewState change handler const handleViewStateChange = React.useMemo( @@ -58,7 +100,11 @@ export default function App() { return (
- {enableRoi && } + {enableRoi && ( + + + + )}
); diff --git a/sites/app/src/roi-selector.d.ts b/sites/app/src/roi-selector.d.ts new file mode 100644 index 00000000..53f97813 --- /dev/null +++ b/sites/app/src/roi-selector.d.ts @@ -0,0 +1,8 @@ +/** + * Type declaration for the optional @biongff/roi-selector plugin. + * This ensures TypeScript doesn't error when the package is not installed. + */ +declare module "@biongff/roi-selector" { + import type * as React from "react"; + export const RoiSelector: React.FC; +} diff --git a/sites/app/vite.config.js b/sites/app/vite.config.js index 2fd211ef..a2fc1c53 100644 --- a/sites/app/vite.config.js +++ b/sites/app/vite.config.js @@ -1,3 +1,4 @@ +import * as fs from "node:fs"; import * as path from "node:path"; import react from "@vitejs/plugin-react"; @@ -16,6 +17,37 @@ export default defineConfig(({ mode }) => ({ } : {}), }, + load(id) { + if (id.startsWith("\0optional:")) { + return "export default {}"; + } + }, }, - server: { open: `?source=${source}` }, })); + +export default defineConfig(({ mode }) => { + // Read workspace config to determine which packages are active + const wsPath = path.resolve(__dirname, "../../pnpm-workspace.yaml"); + const wsContent = fs.readFileSync(wsPath, "utf-8"); + const roiActive = /^\s*-\s*['"]?roi-selector['"]?\s*$/m.test(wsContent); + + return { + plugins: [ + optionalDeps({ "@biongff/roi-selector": "roi-selector" }), + react(), + ], + resolve: { + alias: { + ...(mode === "development" + ? { + "@biongff/vizarr": path.resolve(__dirname, "../../viewer/src/index.tsx"), + ...(roiActive + ? { "@biongff/roi-selector": path.resolve(__dirname, "../../roi-selector/src/index.tsx") } + : {}), + } + : {}), + }, + }, + server: { open: `?source=${source}` }, + }; +}); From 919f3503ef4b931c84c6c8058e5e2f354c612464 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 13:36:17 +0100 Subject: [PATCH 04/32] refactor: moved roi-specific logic and atoms to roi folder --- roi-selector/src/RoiSelector.tsx | 205 ++++++++++++------------ roi-selector/src/index.tsx | 4 + roi-selector/src/state.ts | 94 +++++++++++ roi-selector/src/useRoiDeckExtension.ts | 158 ++++++++++++++++++ viewer/src/components/Viewer.tsx | 187 +++++---------------- viewer/src/index.tsx | 10 +- viewer/src/state.ts | 60 +++---- 7 files changed, 433 insertions(+), 285 deletions(-) create mode 100644 roi-selector/src/state.ts create mode 100644 roi-selector/src/useRoiDeckExtension.ts diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 9d83216a..36133200 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -17,14 +17,20 @@ import React, { useEffect, useRef, useState } from "react"; import { useViewState, currentZInfoAtom, - roiDrawStateAtom, viewportAtom, + setZSliceAtom, +} from "@biongff/vizarr"; + +import { + roiDrawStateAtom, savedRoisAtom, pendingRoiAtom, - ROI_COLORS, - setZSliceAtom, + normalizeRoiBounds, + nextAvailableColor, type SavedRoi, -} from "@biongff/vizarr"; +} from "./state"; + +import { useRoiDeckExtension } from "./useRoiDeckExtension"; /** * RoiSelector — a collapsible panel that lets you: @@ -49,6 +55,9 @@ import { * full image extent. */ function RoiSelector() { + // ---- Register deck.gl extension (overlays, click/hover handlers) ---- + useRoiDeckExtension(); + // -------- local UI state -------- const [open, setOpen] = useState(false); const [roiMenuOpen, setRoiMenuOpen] = useState(false); @@ -91,53 +100,66 @@ function RoiSelector() { return; } if (pendingRoi) { - const { corner1, corner2, z1: pz1, z2: pz2 } = pendingRoi; - setX1(String(Math.min(corner1[0], corner2[0]))); - setY1(String(Math.min(corner1[1], corner2[1]))); - setX2(String(Math.max(corner1[0], corner2[0]))); - setY2(String(Math.max(corner1[1], corner2[1]))); - setZ1(String(Math.min(pz1, pz2))); - setZ2(String(Math.max(pz1, pz2))); + const b = normalizeRoiBounds(pendingRoi); + setX1(String(b.x1)); + setY1(String(b.y1)); + setX2(String(b.x2)); + setY2(String(b.y2)); + setZ1(String(b.z1)); + setZ2(String(b.z2)); } }, [pendingRoi]); - /** Sync text field changes back to the pendingRoi atom so the overlay updates live. */ - const syncFieldsToPending = ( - nx1: string, ny1: string, nx2: string, ny2: string, nz1: string, nz2: string, - ) => { - // When editing a saved ROI, update it in-place for live overlay feedback. - if (editingRoiId) { + /** + * Sync text field changes back to the atom layer so the overlay updates live. + * + * Uses functional updaters to avoid stale-closure issues: the latest atom + * state is always received via the `prev` argument rather than captured + * from the enclosing render scope. + */ + const syncFieldsToPending = React.useCallback( + (nx1: string, ny1: string, nx2: string, ny2: string, nz1: string, nz2: string) => { const px1 = Number(nx1); const py1 = Number(ny1); const px2 = Number(nx2); const py2 = Number(ny2); if ([px1, py1, px2, py2].some(Number.isNaN)) return; - setSavedRois(savedRois.map((r) => { - if (r.id !== editingRoiId) return r; + + const newCorner1: [number, number] = [Math.min(px1, px2), Math.min(py1, py2)]; + const newCorner2: [number, number] = [Math.max(px1, px2), Math.max(py1, py2)]; + + // When editing a saved ROI, update it in-place for live overlay feedback. + if (editingRoiId) { + setSavedRois((prev) => + prev.map((r) => { + if (r.id !== editingRoiId) return r; + return { + ...r, + corner1: newCorner1, + corner2: newCorner2, + z1: nz1 !== "" ? Number(nz1) : r.z1, + z2: nz2 !== "" ? Number(nz2) : r.z2, + }; + }), + ); + return; + } + + // Update the pending ROI using a functional updater so we never + // read a stale `pendingRoi` from the closure. + setPendingRoi((prev) => { + if (!prev) return prev; + internalUpdate.current = true; return { - ...r, - corner1: [Math.min(px1, px2), Math.min(py1, py2)], - corner2: [Math.max(px1, px2), Math.max(py1, py2)], - z1: nz1 !== "" ? Number(nz1) : r.z1, - z2: nz2 !== "" ? Number(nz2) : r.z2, + corner1: newCorner1, + corner2: newCorner2, + z1: nz1 !== "" ? Number(nz1) : prev.z1, + z2: nz2 !== "" ? Number(nz2) : prev.z2, }; - })); - return; - } - if (!pendingRoi) return; - const px1 = Number(nx1); - const py1 = Number(ny1); - const px2 = Number(nx2); - const py2 = Number(ny2); - if ([px1, py1, px2, py2].some(Number.isNaN)) return; - internalUpdate.current = true; - setPendingRoi({ - corner1: [Math.min(px1, px2), Math.min(py1, py2)], - corner2: [Math.max(px1, px2), Math.max(py1, py2)], - z1: nz1 !== "" ? Number(nz1) : pendingRoi.z1, - z2: nz2 !== "" ? Number(nz2) : pendingRoi.z2, - }); - }; + }); + }, + [editingRoiId, setSavedRois, setPendingRoi], + ); // Field change helpers — update local state AND sync to pending overlay. const onX1Change = (v: string) => { setX1(v); syncFieldsToPending(v, y1, x2, y2, z1, z2); }; @@ -168,18 +190,19 @@ function RoiSelector() { const nx2 = Number(x2); const ny2 = Number(y2); if ([nx1, ny1, nx2, ny2].some(Number.isNaN)) return; - const color = ROI_COLORS[savedRois.length % ROI_COLORS.length]; const nz1 = z1 !== "" ? Number(z1) : pendingRoi.z1; const nz2 = z2 !== "" ? Number(z2) : pendingRoi.z2; - setSavedRois([ - ...savedRois, + const raw = { corner1: [nx1, ny1] as [number, number], corner2: [nx2, ny2] as [number, number], z1: nz1, z2: nz2 }; + const b = normalizeRoiBounds(raw); + setSavedRois((prev) => [ + ...prev, { id: Math.random().toString(36).slice(2), - corner1: [Math.min(nx1, nx2), Math.min(ny1, ny2)], - corner2: [Math.max(nx1, nx2), Math.max(ny1, ny2)], - z1: Math.min(nz1, nz2), - z2: Math.max(nz1, nz2), - color, + corner1: [b.x1, b.y1], + corner2: [b.x2, b.y2], + z1: b.z1, + z2: b.z2, + color: nextAvailableColor(prev), visible: true, }, ]); @@ -193,13 +216,13 @@ function RoiSelector() { /** Delete a saved ROI by id. */ const handleDeleteRoi = (id: string) => { - setSavedRois(savedRois.filter((r) => r.id !== id)); + setSavedRois((prev) => prev.filter((r) => r.id !== id)); if (editingRoiId === id) setEditingRoiId(null); }; /** Toggle visibility of a saved ROI. */ const handleToggleVisibility = (id: string) => { - setSavedRois(savedRois.map((r) => (r.id === id ? { ...r, visible: !r.visible } : r))); + setSavedRois((prev) => prev.map((r) => (r.id === id ? { ...r, visible: !r.visible } : r))); }; /** Stash the original values when entering edit mode so we can restore on cancel. */ @@ -212,16 +235,13 @@ function RoiSelector() { if (isDrawing) setRoiDrawState(null); editOriginal.current = { ...roi }; setEditingRoiId(roi.id); - const minX = Math.min(roi.corner1[0], roi.corner2[0]); - const minY = Math.min(roi.corner1[1], roi.corner2[1]); - const maxX = Math.max(roi.corner1[0], roi.corner2[0]); - const maxY = Math.max(roi.corner1[1], roi.corner2[1]); - setX1(String(minX)); - setY1(String(minY)); - setX2(String(maxX)); - setY2(String(maxY)); - setZ1(String(Math.min(roi.z1, roi.z2))); - setZ2(String(Math.max(roi.z1, roi.z2))); + const b = normalizeRoiBounds(roi); + setX1(String(b.x1)); + setY1(String(b.y1)); + setX2(String(b.x2)); + setY2(String(b.y2)); + setZ1(String(b.z1)); + setZ2(String(b.z2)); }; /** Finish editing: commit current field values (already live-synced) and exit edit mode. */ @@ -234,7 +254,7 @@ function RoiSelector() { const handleCancelEdit = () => { if (editOriginal.current && editingRoiId) { const orig = editOriginal.current; - setSavedRois(savedRois.map((r) => (r.id === editingRoiId ? orig : r))); + setSavedRois((prev) => prev.map((r) => (r.id === editingRoiId ? orig : r))); } editOriginal.current = null; setEditingRoiId(null); @@ -246,12 +266,9 @@ function RoiSelector() { /** Navigate the viewer to a specific saved ROI (XY + Z) and ensure visibility. */ const handleGoToSavedRoi = (roi: SavedRoi) => { if (!viewport) return; - const minX = Math.min(roi.corner1[0], roi.corner2[0]); - const maxX = Math.max(roi.corner1[0], roi.corner2[0]); - const minY = Math.min(roi.corner1[1], roi.corner2[1]); - const maxY = Math.max(roi.corner1[1], roi.corner2[1]); - const roiWidth = maxX - minX; - const roiHeight = maxY - minY; + const b = normalizeRoiBounds(roi); + const roiWidth = b.x2 - b.x1; + const roiHeight = b.y2 - b.y1; if (roiWidth === 0 || roiHeight === 0) return; const padding = 40; const availableWidth = viewport.width - 2 * padding; @@ -259,48 +276,39 @@ function RoiSelector() { const zoom = Math.log2(Math.min(availableWidth / roiWidth, availableHeight / roiHeight)); setViewState({ zoom, - target: [(minX + maxX) / 2, (minY + maxY) / 2], + target: [(b.x1 + b.x2) / 2, (b.y1 + b.y2) / 2], width: viewport.width, height: viewport.height, }); // Navigate to the ROI's Z plane (use the start of its range) if (hasZAxis) { - setZSlice(Math.min(roi.z1, roi.z2)); + setZSlice(b.z1); } // Ensure the ROI is visible if (!roi.visible) { - setSavedRois(savedRois.map((r) => (r.id === roi.id ? { ...r, visible: true } : r))); + setSavedRois((prev) => prev.map((r) => (r.id === roi.id ? { ...r, visible: true } : r))); } }; - /** Copy a single ROI's coordinates to clipboard. */ - const handleCopySingleRoi = (roi: SavedRoi) => { - const minX = Math.min(roi.corner1[0], roi.corner2[0]); - const minY = Math.min(roi.corner1[1], roi.corner2[1]); - const maxX = Math.max(roi.corner1[0], roi.corner2[0]); - const maxY = Math.max(roi.corner1[1], roi.corner2[1]); - const payload: Record = { x1: minX, y1: minY, x2: maxX, y2: maxY }; + /** Build a clipboard payload from a ROI. */ + const roiToPayload = (roi: SavedRoi): Record => { + const b = normalizeRoiBounds(roi); + const payload: Record = { x1: b.x1, y1: b.y1, x2: b.x2, y2: b.y2 }; if (hasZAxis) { - payload.z1 = Math.min(roi.z1, roi.z2); - payload.z2 = Math.max(roi.z1, roi.z2); + payload.z1 = b.z1; + payload.z2 = b.z2; } - navigator.clipboard.writeText(JSON.stringify(payload)).then(() => setSnackOpen(true)); + return payload; + }; + + /** Copy a single ROI's coordinates to clipboard. */ + const handleCopySingleRoi = (roi: SavedRoi) => { + navigator.clipboard.writeText(JSON.stringify(roiToPayload(roi))).then(() => setSnackOpen(true)); }; /** Copy all saved ROIs to clipboard as a JSON array. */ const handleCopyAllRois = () => { - const arr = savedRois.map((roi) => { - const minX = Math.min(roi.corner1[0], roi.corner2[0]); - const minY = Math.min(roi.corner1[1], roi.corner2[1]); - const maxX = Math.max(roi.corner1[0], roi.corner2[0]); - const maxY = Math.max(roi.corner1[1], roi.corner2[1]); - const payload: Record = { x1: minX, y1: minY, x2: maxX, y2: maxY }; - if (hasZAxis) { - payload.z1 = Math.min(roi.z1, roi.z2); - payload.z2 = Math.max(roi.z1, roi.z2); - } - return payload; - }); + const arr = savedRois.map(roiToPayload); navigator.clipboard.writeText(JSON.stringify(arr, null, 2)).then(() => setSnackOpen(true)); }; @@ -552,12 +560,7 @@ function RoiSelector() { {savedRois.map((roi) => { - const minX = Math.min(roi.corner1[0], roi.corner2[0]); - const minY = Math.min(roi.corner1[1], roi.corner2[1]); - const maxX = Math.max(roi.corner1[0], roi.corner2[0]); - const maxY = Math.max(roi.corner1[1], roi.corner2[1]); - const roiZ1 = Math.min(roi.z1, roi.z2); - const roiZ2 = Math.max(roi.z1, roi.z2); + const b = normalizeRoiBounds(roi); return ( - ({minX}, {minY}) → ({maxX}, {maxY}) + ({b.x1}, {b.y1}) → ({b.x2}, {b.y2}) {hasZAxis && ( - z: {roiZ1 === roiZ2 ? roiZ1 : `${roiZ1}–${roiZ2}`} + z: {b.z1 === b.z2 ? b.z1 : `${b.z1}–${b.z2}`} )} diff --git a/roi-selector/src/index.tsx b/roi-selector/src/index.tsx index 9e8752e2..483c4838 100644 --- a/roi-selector/src/index.tsx +++ b/roi-selector/src/index.tsx @@ -1 +1,5 @@ export { default as RoiSelector } from "./RoiSelector"; + +// Re-export ROI state for programmatic access +export { roiDrawStateAtom, savedRoisAtom, pendingRoiAtom, ROI_COLORS, normalizeRoiBounds, nextAvailableColor } from "./state"; +export type { RoiDrawState, SavedRoi, PendingRoi, NormalizedBounds } from "./state"; diff --git a/roi-selector/src/state.ts b/roi-selector/src/state.ts new file mode 100644 index 00000000..9d2e43be --- /dev/null +++ b/roi-selector/src/state.ts @@ -0,0 +1,94 @@ +import { atom } from "jotai"; + +/** + * Shared state for the "draw ROI on image" feature. + * + * State machine: + * null → draw mode is OFF + * "waiting-first" → draw mode ON, waiting for the first click + * { corner1, z1 } → first corner placed, waiting for second click + */ +export type RoiDrawState = null | "waiting-first" | { corner1: [number, number]; z1: number }; +export const roiDrawStateAtom = atom(null); + +/** A saved ROI with its assigned overlay color. */ +export interface SavedRoi { + id: string; + corner1: [number, number]; + corner2: [number, number]; + z1: number; + z2: number; + color: [number, number, number]; + visible: boolean; +} + +/** A ROI that has been drawn but not yet saved or discarded. */ +export interface PendingRoi { + corner1: [number, number]; + corner2: [number, number]; + z1: number; + z2: number; +} + +export const savedRoisAtom = atom([]); +export const pendingRoiAtom = atom(null); + +/** Normalized bounding box with guaranteed min/max ordering. */ +export interface NormalizedBounds { + x1: number; + y1: number; + x2: number; + y2: number; + z1: number; + z2: number; +} + +/* + * Normalize a ROI's coordinates so that (x1,y1) is the top-left and + * (x2,y2) is the bottom-right, with z1 ≤ z2. + * + * Works for both SavedRoi and PendingRoi. + */ +export function normalizeRoiBounds(roi: { corner1: [number, number]; corner2: [number, number]; z1: number; z2: number }): NormalizedBounds { + return { + x1: Math.min(roi.corner1[0], roi.corner2[0]), + y1: Math.min(roi.corner1[1], roi.corner2[1]), + x2: Math.max(roi.corner1[0], roi.corner2[0]), + y2: Math.max(roi.corner1[1], roi.corner2[1]), + z1: Math.min(roi.z1, roi.z2), + z2: Math.max(roi.z1, roi.z2), + }; +} + +/** Color palette (RGB) cycled through for multi-ROI overlays. */ +export const ROI_COLORS: [number, number, number][] = [ + [255, 100, 100], // red + [100, 180, 255], // blue + [100, 220, 100], // green + [255, 200, 50], // yellow + [200, 100, 255], // purple + [255, 150, 50], // orange + [50, 220, 200], // teal + [255, 100, 200], // pink + [180, 220, 80], // lime + [255, 130, 130], // salmon + [130, 130, 255], // periwinkle + [255, 180, 180], // light coral + [80, 200, 140], // mint + [220, 160, 255], // lavender + [255, 220, 100], // gold + [100, 200, 200], // cyan +]; + +/** + * Pick the first color from `ROI_COLORS` that isn't already used by any + * existing ROI. Falls back to cycling if all colors are taken. + */ +export function nextAvailableColor(existingRois: SavedRoi[]): [number, number, number] { + const usedSet = new Set(existingRois.map((r) => r.color.join(","))); + for (const color of ROI_COLORS) { + if (!usedSet.has(color.join(","))) return color; + } + // All colors in use — cycle based on count + return ROI_COLORS[existingRois.length % ROI_COLORS.length]; +} diff --git a/roi-selector/src/useRoiDeckExtension.ts b/roi-selector/src/useRoiDeckExtension.ts new file mode 100644 index 00000000..8e39ade2 --- /dev/null +++ b/roi-selector/src/useRoiDeckExtension.ts @@ -0,0 +1,158 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { + currentZInfoAtom, + deckExtensionsAtom, + type OverlayPolygon, +} from "@biongff/vizarr"; + +import { + roiDrawStateAtom, + savedRoisAtom, + pendingRoiAtom, + nextAvailableColor, + normalizeRoiBounds, +} from "./state"; + +/** + * Hook that registers ROI overlay layers, click and hover handlers + * with the viewer's deck.gl extension system. + * + * This keeps all ROI ↔ deck.gl interaction out of the core Viewer component. + * Call this once from the top-level RoiSelector component. + */ +export function useRoiDeckExtension() { + const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); + const isDrawing = roiDrawState !== null; + const savedRois = useAtomValue(savedRoisAtom); + const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); + const zInfo = useAtomValue(currentZInfoAtom); + const currentZ = zInfo?.zValue ?? null; + + const nextRoiColor = nextAvailableColor(savedRois); + + const roiCorner1 = + roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState + ? roiDrawState.corner1 + : null; + + // Track mouse position for the preview rectangle (only while placing second corner). + const [roiMousePos, setRoiMousePos] = useState<[number, number] | null>(null); + + const setExtensions = useSetAtom(deckExtensionsAtom); + + // ---- Build overlay polygon specifications ---- + const overlays = useMemo(() => { + const result: OverlayPolygon[] = []; + + // Saved ROIs — filtered by visibility and current Z plane + for (const roi of savedRois) { + if (!roi.visible) continue; + if (currentZ !== null) { + const b = normalizeRoiBounds(roi); + if (currentZ < b.z1 || currentZ > b.z2) continue; + } + const [ax, ay] = roi.corner1; + const [bx, by] = roi.corner2; + result.push({ + id: `roi-saved-${roi.id}`, + polygon: [[ax, ay], [bx, ay], [bx, by], [ax, by]], + fillColor: [...roi.color, 40], + lineColor: [...roi.color, 200], + }); + } + + // Pending ROI (drawn but not yet saved/discarded) + if (pendingRoi) { + const [ax, ay] = pendingRoi.corner1; + const [bx, by] = pendingRoi.corner2; + result.push({ + id: "roi-pending", + polygon: [[ax, ay], [bx, ay], [bx, by], [ax, by]], + fillColor: [...nextRoiColor, 60], + lineColor: [...nextRoiColor, 220], + }); + } + + // Preview rectangle (corner1 placed, following mouse) + if (roiCorner1 && roiMousePos) { + const [x1, y1] = roiCorner1; + const [x2, y2] = roiMousePos; + result.push({ + id: "roi-preview", + polygon: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]], + fillColor: [...nextRoiColor, 40], + lineColor: [...nextRoiColor, 200], + }); + } + + return result; + }, [savedRois, pendingRoi, nextRoiColor, currentZ, roiCorner1, roiMousePos]); + + // ---- Click handler (place ROI corners) ---- + const onClick = useCallback( + (coordinate: [number, number]): boolean => { + if (!isDrawing) return false; + + const [x, y] = coordinate; + + if (roiDrawState === "waiting-first") { + const z1 = zInfo?.zValue ?? 0; + setRoiDrawState({ corner1: [Math.round(x), Math.round(y)], z1 }); + return true; + } + + if (roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState) { + const corner2: [number, number] = [Math.round(x), Math.round(y)]; + const z2 = zInfo?.zValue ?? 0; + setPendingRoi({ + corner1: roiDrawState.corner1, + corner2, + z1: roiDrawState.z1, + z2, + }); + setRoiDrawState(null); + return true; + } + + return false; + }, + [isDrawing, roiDrawState, setRoiDrawState, setPendingRoi, zInfo], + ); + + // ---- Hover handler (track mouse for preview rectangle) ---- + const onHover = useCallback( + (coordinate: [number, number] | null) => { + if (roiCorner1 && coordinate) { + setRoiMousePos(coordinate); + } else { + setRoiMousePos(null); + } + }, + [roiCorner1], + ); + + // ---- Register / update extension ---- + useEffect(() => { + setExtensions((prev) => ({ + ...prev, + "roi-selector": { + overlays, + onClick, + onHover, + cursor: isDrawing ? "crosshair" : undefined, + }, + })); + }, [overlays, onClick, onHover, isDrawing, setExtensions]); + + // ---- Cleanup on unmount ---- + useEffect(() => { + return () => { + setExtensions((prev) => { + const { "roi-selector": _, ...rest } = prev; + return rest; + }); + }; + }, [setExtensions]); +} diff --git a/viewer/src/components/Viewer.tsx b/viewer/src/components/Viewer.tsx index 40e26fcf..cfd0df19 100644 --- a/viewer/src/components/Viewer.tsx +++ b/viewer/src/components/Viewer.tsx @@ -5,7 +5,7 @@ import { useAtom, useAtomValue } from "jotai"; import * as React from "react"; import { useViewState } from "../hooks"; import { useAxisNavigation } from "../hooks/useAxisNavigation"; -import { layerAtoms, currentZInfoAtom, roiDrawStateAtom, viewportAtom, savedRoisAtom, pendingRoiAtom, ROI_COLORS } from "../state"; +import { layerAtoms, deckExtensionsAtom, viewportAtom } from "../state"; import { fitImageToViewport, getLayerSize, resolveLoaderFromLayerProps } from "../utils"; import type { DeckGLRef, OrthographicViewState, PickingInfo } from "deck.gl"; @@ -19,27 +19,9 @@ export default function Viewer() { const layers = useAtomValue(layerAtoms); const firstLayer = layers[0] as VizarrLayer; const axisNavigationSnackbar = useAxisNavigation(deckRef, viewport); - // ---- ROI draw-on-image support ---- - // Read the shared draw-mode atom so we know whether to intercept clicks. - const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); - const isDrawing = roiDrawState !== null; - // Current Z-axis info (may be null if there's no Z axis). - const zInfo = useAtomValue(currentZInfoAtom); - - // Track the current mouse position in image coordinates for the preview rectangle. - const [roiMousePos, setRoiMousePos] = React.useState<[number, number] | null>(null); - - // The first corner (if placed) — extracted for convenience. - const roiCorner1 = - roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState - ? roiDrawState.corner1 - : null; - - // ---- Multi-ROI state ---- - const savedRois = useAtomValue(savedRoisAtom); - const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); - const nextRoiColor = ROI_COLORS[savedRois.length % ROI_COLORS.length]; + // ---- Plugin extension system ---- + const extensions = useAtomValue(deckExtensionsAtom); const resetViewState = React.useCallback( (layer: VizarrLayer) => { @@ -156,146 +138,65 @@ export default function Viewer() { }; }, [layers]); - /** - * Handle clicks on the deck.gl canvas. -> Needed for ROI drawing in RoiSelector - * - * When draw mode is active (`roiDrawState !== null`), clicks are - * intercepted to place ROI corners instead of doing the default - * pick / tooltip behaviour. - * - * `info.coordinate` is the [x, y] position in **image space** (world - * coordinates) — exactly what we need for the ROI bounding box. - */ + // ---- Extension layers (polygon overlays from plugins) ---- + const extensionLayers = React.useMemo(() => { + return Object.values(extensions).flatMap((ext) => + (ext.overlays ?? []).map( + (overlay) => + new PolygonLayer({ + id: overlay.id, + data: [{ polygon: overlay.polygon }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: overlay.fillColor, + getLineColor: overlay.lineColor, + getLineWidth: overlay.lineWidth ?? 2, + lineWidthUnits: "pixels" as const, + stroked: true, + filled: true, + pickable: false, + }), + ), + ); + }, [extensions]); + + // ---- Generic click handler (delegates to registered extensions) ---- const handleClick = React.useCallback( (info: PickingInfo) => { - if (!isDrawing || !info.coordinate) return; - - const [x, y] = info.coordinate; - - if (roiDrawState === "waiting-first") { - // First click → record corner 1 + current Z, wait for corner 2 - const z1 = zInfo?.zValue ?? 0; - setRoiDrawState({ corner1: [Math.round(x), Math.round(y)], z1 }); - } else if (roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState) { - // Second click → set as pending ROI for save/discard in RoiSelector. - const corner2: [number, number] = [Math.round(x), Math.round(y)]; - const z2 = zInfo?.zValue ?? 0; - setPendingRoi({ - corner1: roiDrawState.corner1, - corner2, - z1: roiDrawState.z1, - z2, - }); - setRoiDrawState(null); + if (!info.coordinate) return; + const coord: [number, number] = [info.coordinate[0], info.coordinate[1]]; + for (const ext of Object.values(extensions)) { + if (ext.onClick?.(coord)) return; } }, - [isDrawing, roiDrawState, setRoiDrawState, setPendingRoi, zInfo], + [extensions], ); - // Track mouse movement in image coordinates while waiting for the second corner. + // ---- Generic hover handler (delegates to registered extensions) ---- const handleHover = React.useCallback( (info: PickingInfo) => { - if (roiCorner1 && info.coordinate) { - setRoiMousePos([info.coordinate[0], info.coordinate[1]]); - } else { - setRoiMousePos(null); + const coord = info.coordinate + ? ([info.coordinate[0], info.coordinate[1]] as [number, number]) + : null; + for (const ext of Object.values(extensions)) { + ext.onHover?.(coord); } }, - [roiCorner1], + [extensions], ); - // Build a preview rectangle layer when corner1 is placed and cursor is moving. - const roiPreviewLayer = React.useMemo(() => { - if (!roiCorner1 || !roiMousePos) return null; - const [x1, y1] = roiCorner1; - const [x2, y2] = roiMousePos; - return new PolygonLayer({ - id: "roi-preview", - data: [ - { - polygon: [ - [x1, y1], - [x2, y1], - [x2, y2], - [x1, y2], - ], - }, - ], - getPolygon: (d: { polygon: [number, number][] }) => d.polygon, - getFillColor: [...nextRoiColor, 40] as [number, number, number, number], - getLineColor: [...nextRoiColor, 200] as [number, number, number, number], - getLineWidth: 2, - lineWidthUnits: "pixels", - stroked: true, - filled: true, - pickable: false, - }); - }, [roiCorner1, roiMousePos, nextRoiColor]); - - // Build polygon overlay layers for saved ROIs and the pending (unsaved) ROI. - // Only show saved ROIs that are visible AND whose z-range includes the current z slice. - const currentZ = zInfo?.zValue ?? null; - const roiOverlayLayers = React.useMemo(() => { - const overlays = []; - - // Saved ROIs — filtered by visibility and current Z plane - for (const roi of savedRois) { - if (!roi.visible) continue; - // If there's a Z axis, only show ROI when current Z is within its z range - if (currentZ !== null) { - const zMin = Math.min(roi.z1, roi.z2); - const zMax = Math.max(roi.z1, roi.z2); - if (currentZ < zMin || currentZ > zMax) continue; - } - const [ax, ay] = roi.corner1; - const [bx, by] = roi.corner2; - overlays.push( - new PolygonLayer({ - id: `roi-saved-${roi.id}`, - data: [{ polygon: [[ax, ay], [bx, ay], [bx, by], [ax, by]] }], - getPolygon: (d: { polygon: [number, number][] }) => d.polygon, - getFillColor: [...roi.color, 40] as [number, number, number, number], - getLineColor: [...roi.color, 200] as [number, number, number, number], - getLineWidth: 2, - lineWidthUnits: "pixels" as const, - stroked: true, - filled: true, - pickable: false, - }), - ); + // ---- Generic cursor (first extension with a cursor wins, else "grab") ---- + const getCursor = React.useCallback(() => { + for (const ext of Object.values(extensions)) { + if (ext.cursor) return ext.cursor; } - - // Pending ROI (drawn but not yet saved/discarded) - if (pendingRoi) { - const [ax, ay] = pendingRoi.corner1; - const [bx, by] = pendingRoi.corner2; - overlays.push( - new PolygonLayer({ - id: "roi-pending", - data: [{ polygon: [[ax, ay], [bx, ay], [bx, by], [ax, by]] }], - getPolygon: (d: { polygon: [number, number][] }) => d.polygon, - getFillColor: [...nextRoiColor, 60] as [number, number, number, number], - getLineColor: [...nextRoiColor, 220] as [number, number, number, number], - getLineWidth: 2, - lineWidthUnits: "pixels" as const, - stroked: true, - filled: true, - pickable: false, - }), - ); - } - - return overlays; - }, [savedRois, pendingRoi, nextRoiColor, currentZ]); - - // Change the cursor to crosshair while draw mode is active - const getCursor = React.useCallback(() => (isDrawing ? "crosshair" : "grab"), [isDrawing]); + return "grab"; + }, [extensions]); return ( <> diff --git a/viewer/src/index.tsx b/viewer/src/index.tsx index f84e557a..eff29856 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -9,7 +9,11 @@ export type { VizarrViewer } from "./api"; export type { ViewState, ImageLayerConfig } from "./state"; -// ROI-related atoms & hooks — used by the @biongff/roi-selector plugin -export { roiDrawStateAtom, currentZInfoAtom, viewportAtom, savedRoisAtom, pendingRoiAtom, ROI_COLORS, setZSliceAtom } from "./state"; -export type { RoiDrawState, SavedRoi, PendingRoi } from "./state"; +// Plugin extension system +export { deckExtensionsAtom, viewportAtom } from "./state"; +export type { DeckExtension, OverlayPolygon } from "./state"; + +// Z-axis utilities +export { currentZInfoAtom, setZSliceAtom } from "./state"; + export { useViewState, ViewStateContext } from "./hooks"; diff --git a/viewer/src/state.ts b/viewer/src/state.ts index e9dadda5..f9701142 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -115,50 +115,34 @@ export const viewStateAtom = atom(null); export const sourceErrorAtom = atom(null); export const sourceWarningAtom = atom([]); -/** - * Shared state for the "draw ROI on image" feature. - * - * State machine: - * null → draw mode is OFF - * "waiting-first" → draw mode ON, waiting for the first click - * { corner1, z1 } → first corner placed, waiting for second click - */ -export type RoiDrawState = null | "waiting-first" | { corner1: [number, number]; z1: number }; -export const roiDrawStateAtom = atom(null); +// ---- Plugin extension system ---- -/** A saved ROI with its assigned overlay color. */ -export interface SavedRoi { +/** A polygon overlay specification (plain data, converted to deck.gl layers by the Viewer). */ +export interface OverlayPolygon { id: string; - corner1: [number, number]; - corner2: [number, number]; - z1: number; - z2: number; - color: [number, number, number]; - visible: boolean; + polygon: [number, number][]; + fillColor: [number, number, number, number]; + lineColor: [number, number, number, number]; + lineWidth?: number; } -/** A ROI that has been drawn but not yet saved or discarded. */ -export interface PendingRoi { - corner1: [number, number]; - corner2: [number, number]; - z1: number; - z2: number; +/** + * Extension interface for plugins to inject behavior into the deck.gl viewer. + * Plugins register an extension via the `deckExtensionsAtom`. + */ +export interface DeckExtension { + /** Polygon overlay specifications to render on the canvas. */ + overlays?: OverlayPolygon[]; + /** Click handler. Receives image-space coordinates. Return true to consume the event. */ + onClick?: (coordinate: [number, number]) => boolean; + /** Hover handler. Receives image-space coordinates or null when leaving the canvas. */ + onHover?: (coordinate: [number, number] | null) => void; + /** Cursor to show when this extension is active. */ + cursor?: string; } -export const savedRoisAtom = atom([]); -export const pendingRoiAtom = atom(null); - -/** Color palette (RGB) cycled through for multi-ROI overlays. */ -export const ROI_COLORS: [number, number, number][] = [ - [255, 100, 100], // red - [100, 180, 255], // blue - [100, 220, 100], // green - [255, 200, 50], // yellow - [200, 100, 255], // purple - [255, 150, 50], // orange - [50, 220, 200], // teal - [255, 100, 200], // pink -]; +/** Registry of deck.gl extensions keyed by unique ID. */ +export const deckExtensionsAtom = atom>({}); /** * Derived atom that exposes the current Z-axis selection and metadata From fce69f756a81db41a41da3cc2f0c9d2bb52ddff2 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 13:36:17 +0100 Subject: [PATCH 05/32] feat: bounds ROI (x,y,z) to image sizes --- roi-selector/src/RoiSelector.tsx | 39 ++++++++++++++++++------- roi-selector/src/index.tsx | 4 +-- roi-selector/src/state.ts | 29 ++++++++++++++++-- roi-selector/src/useRoiDeckExtension.ts | 23 ++++++++++----- viewer/src/index.tsx | 4 +-- viewer/src/state.ts | 22 ++++++++++++++ 6 files changed, 96 insertions(+), 25 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 36133200..c5b57f01 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -17,6 +17,7 @@ import React, { useEffect, useRef, useState } from "react"; import { useViewState, currentZInfoAtom, + currentImageBoundsAtom, viewportAtom, setZSliceAtom, } from "@biongff/vizarr"; @@ -26,6 +27,7 @@ import { savedRoisAtom, pendingRoiAtom, normalizeRoiBounds, + clampToBounds, nextAvailableColor, type SavedRoi, } from "./state"; @@ -78,6 +80,7 @@ function RoiSelector() { // -------- Z-axis info from first layer -------- const zInfo = useAtomValue(currentZInfoAtom); + const imageBounds = useAtomValue(currentImageBoundsAtom); const hasZAxis = zInfo !== null; // -------- draw-on-image state -------- @@ -100,7 +103,8 @@ function RoiSelector() { return; } if (pendingRoi) { - const b = normalizeRoiBounds(pendingRoi); + const bn = normalizeRoiBounds(pendingRoi); + const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; setX1(String(b.x1)); setY1(String(b.y1)); setX2(String(b.x2)); @@ -193,7 +197,7 @@ function RoiSelector() { const nz1 = z1 !== "" ? Number(z1) : pendingRoi.z1; const nz2 = z2 !== "" ? Number(z2) : pendingRoi.z2; const raw = { corner1: [nx1, ny1] as [number, number], corner2: [nx2, ny2] as [number, number], z1: nz1, z2: nz2 }; - const b = normalizeRoiBounds(raw); + const b = imageBounds ? clampToBounds(normalizeRoiBounds(raw), imageBounds) : normalizeRoiBounds(raw); setSavedRois((prev) => [ ...prev, { @@ -235,7 +239,8 @@ function RoiSelector() { if (isDrawing) setRoiDrawState(null); editOriginal.current = { ...roi }; setEditingRoiId(roi.id); - const b = normalizeRoiBounds(roi); + const bn = normalizeRoiBounds(roi); + const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; setX1(String(b.x1)); setY1(String(b.y1)); setX2(String(b.x2)); @@ -366,24 +371,30 @@ function RoiSelector() { onX1Change(e.target.value)} fullWidth - slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} + slotProps={{ + input: { sx: { color: "#fff", fontSize: 12 } }, + htmlInput: { min: 0, max: imageBounds?.xMax }, + }} /> onY1Change(e.target.value)} fullWidth - slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} + slotProps={{ + input: { sx: { color: "#fff", fontSize: 12 } }, + htmlInput: { min: 0, max: imageBounds?.yMax }, + }} /> @@ -395,24 +406,30 @@ function RoiSelector() { onX2Change(e.target.value)} fullWidth - slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} + slotProps={{ + input: { sx: { color: "#fff", fontSize: 12 } }, + htmlInput: { min: 0, max: imageBounds?.xMax }, + }} /> onY2Change(e.target.value)} fullWidth - slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} + slotProps={{ + input: { sx: { color: "#fff", fontSize: 12 } }, + htmlInput: { min: 0, max: imageBounds?.yMax }, + }} /> diff --git a/roi-selector/src/index.tsx b/roi-selector/src/index.tsx index 483c4838..b6542f37 100644 --- a/roi-selector/src/index.tsx +++ b/roi-selector/src/index.tsx @@ -1,5 +1,5 @@ export { default as RoiSelector } from "./RoiSelector"; // Re-export ROI state for programmatic access -export { roiDrawStateAtom, savedRoisAtom, pendingRoiAtom, ROI_COLORS, normalizeRoiBounds, nextAvailableColor } from "./state"; -export type { RoiDrawState, SavedRoi, PendingRoi, NormalizedBounds } from "./state"; +export { roiDrawStateAtom, savedRoisAtom, pendingRoiAtom, ROI_COLORS, normalizeRoiBounds, nextAvailableColor, clampToBounds } from "./state"; +export type { RoiDrawState, SavedRoi, PendingRoi, NormalizedBounds, ImageBounds } from "./state"; diff --git a/roi-selector/src/state.ts b/roi-selector/src/state.ts index 9d2e43be..c6a4b2ff 100644 --- a/roi-selector/src/state.ts +++ b/roi-selector/src/state.ts @@ -47,7 +47,7 @@ export interface NormalizedBounds { * Normalize a ROI's coordinates so that (x1,y1) is the top-left and * (x2,y2) is the bottom-right, with z1 ≤ z2. * - * Works for both SavedRoi and PendingRoi. + * Works for both `SavedRoi` and `PendingRoi` obj. */ export function normalizeRoiBounds(roi: { corner1: [number, number]; corner2: [number, number]; z1: number; z2: number }): NormalizedBounds { return { @@ -60,7 +60,30 @@ export function normalizeRoiBounds(roi: { corner1: [number, number]; corner2: [n }; } -/** Color palette (RGB) cycled through for multi-ROI overlays. */ +/** Spatial dimensions of the loaded image, used for bounds clamping. */ +export interface ImageBounds { + xMax: number; + yMax: number; + zMax: number | null; +} + +/** + * Clamp a normalized ROI to the image boundaries so coordinates stay within + * [0, xMax] × [0, yMax] (and [0, zMax] when a Z axis is present). + */ +export function clampToBounds(b: NormalizedBounds, image: ImageBounds): NormalizedBounds { + const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); + return { + x1: clamp(b.x1, 0, image.xMax), + y1: clamp(b.y1, 0, image.yMax), + x2: clamp(b.x2, 0, image.xMax), + y2: clamp(b.y2, 0, image.yMax), + z1: image.zMax !== null ? clamp(b.z1, 0, image.zMax) : b.z1, + z2: image.zMax !== null ? clamp(b.z2, 0, image.zMax) : b.z2, + }; +} + +/* Color palette (RGB) cycled through for multi-ROI overlays. */ export const ROI_COLORS: [number, number, number][] = [ [255, 100, 100], // red [100, 180, 255], // blue @@ -80,7 +103,7 @@ export const ROI_COLORS: [number, number, number][] = [ [100, 200, 200], // cyan ]; -/** +/* * Pick the first color from `ROI_COLORS` that isn't already used by any * existing ROI. Falls back to cycling if all colors are taken. */ diff --git a/roi-selector/src/useRoiDeckExtension.ts b/roi-selector/src/useRoiDeckExtension.ts index 8e39ade2..cfb9c7ec 100644 --- a/roi-selector/src/useRoiDeckExtension.ts +++ b/roi-selector/src/useRoiDeckExtension.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { currentZInfoAtom, + currentImageBoundsAtom, deckExtensionsAtom, type OverlayPolygon, } from "@biongff/vizarr"; @@ -28,6 +29,7 @@ export function useRoiDeckExtension() { const savedRois = useAtomValue(savedRoisAtom); const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); const zInfo = useAtomValue(currentZInfoAtom); + const imageBounds = useAtomValue(currentImageBoundsAtom); const currentZ = zInfo?.zValue ?? null; const nextRoiColor = nextAvailableColor(savedRois); @@ -90,22 +92,29 @@ export function useRoiDeckExtension() { return result; }, [savedRois, pendingRoi, nextRoiColor, currentZ, roiCorner1, roiMousePos]); - // ---- Click handler (place ROI corners) ---- + // ---- Click handler (place ROI corners, clamped to image bounds) ---- const onClick = useCallback( (coordinate: [number, number]): boolean => { if (!isDrawing) return false; - const [x, y] = coordinate; + const [rawX, rawY] = coordinate; + const clampXY = (v: number, max: number) => Math.max(0, Math.min(Math.round(v), max)); + const x = imageBounds ? clampXY(rawX, imageBounds.xMax) : Math.round(rawX); + const y = imageBounds ? clampXY(rawY, imageBounds.yMax) : Math.round(rawY); + const clampZ = (z: number) => + imageBounds?.zMax !== null && imageBounds?.zMax !== undefined + ? Math.max(0, Math.min(z, imageBounds.zMax)) + : z; if (roiDrawState === "waiting-first") { - const z1 = zInfo?.zValue ?? 0; - setRoiDrawState({ corner1: [Math.round(x), Math.round(y)], z1 }); + const z1 = clampZ(zInfo?.zValue ?? 0); + setRoiDrawState({ corner1: [x, y], z1 }); return true; } if (roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState) { - const corner2: [number, number] = [Math.round(x), Math.round(y)]; - const z2 = zInfo?.zValue ?? 0; + const corner2: [number, number] = [x, y]; + const z2 = clampZ(zInfo?.zValue ?? 0); setPendingRoi({ corner1: roiDrawState.corner1, corner2, @@ -118,7 +127,7 @@ export function useRoiDeckExtension() { return false; }, - [isDrawing, roiDrawState, setRoiDrawState, setPendingRoi, zInfo], + [isDrawing, roiDrawState, setRoiDrawState, setPendingRoi, zInfo, imageBounds], ); // ---- Hover handler (track mouse for preview rectangle) ---- diff --git a/viewer/src/index.tsx b/viewer/src/index.tsx index eff29856..f46e3d7e 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -13,7 +13,7 @@ export type { ViewState, ImageLayerConfig } from "./state"; export { deckExtensionsAtom, viewportAtom } from "./state"; export type { DeckExtension, OverlayPolygon } from "./state"; -// Z-axis utilities -export { currentZInfoAtom, setZSliceAtom } from "./state"; +// Z-axis and image-bounds utilities +export { currentZInfoAtom, setZSliceAtom, currentImageBoundsAtom } from "./state"; export { useViewState, ViewStateContext } from "./hooks"; diff --git a/viewer/src/state.ts b/viewer/src/state.ts index f9701142..81635894 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -161,6 +161,28 @@ export const currentZInfoAtom = atom((get) => { return { zValue, zMax }; }); +/** + * Derived atom that exposes the spatial X/Y (and optionally Z) size of the + * first loaded source. This is the authoritative bound for ROI coordinates. + * Returns null when no source has been loaded yet, or when x/y axes cannot + * be found in the axis labels. + */ +export const currentImageBoundsAtom = atom((get) => { + const sources = get(sourceInfoAtom); + if (sources.length === 0) return null; + const source = sources[0]; + const loader = source.loader[0]; + const xAxisIndex = source.axis_labels.indexOf("x"); + const yAxisIndex = source.axis_labels.indexOf("y"); + if (xAxisIndex === -1 || yAxisIndex === -1) return null; + const zAxisIndex = source.axis_labels.indexOf("z"); + return { + xMax: loader.shape[xAxisIndex] - 1, + yMax: loader.shape[yAxisIndex] - 1, + zMax: zAxisIndex !== -1 ? loader.shape[zAxisIndex] - 1 : null, + }; +}); + /** * Write-only atom that sets the Z-axis slice for all loaded sources. * Pass a z index number and it will update every source's selection. From 4f12409e80c8db7fba5b8627ffbd22ee2502e92a Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 13:36:17 +0100 Subject: [PATCH 06/32] refactor: divided roi selector into components for ease of maintenance --- roi-selector/src/RoiSelector.tsx | 707 +++--------------- .../src/components/RoiCoordinateFields.tsx | 143 ++++ .../src/components/RoiDrawControls.tsx | 111 +++ roi-selector/src/components/SavedRoiItem.tsx | 116 +++ roi-selector/src/components/SavedRoiList.tsx | 101 +++ roi-selector/src/hooks/useRoiFields.ts | 257 +++++++ viewer/src/components/Viewer.tsx | 11 +- viewer/src/hooks/useAxisNavigation.tsx | 16 +- viewer/src/index.tsx | 1 + viewer/src/state.ts | 10 +- 10 files changed, 839 insertions(+), 634 deletions(-) create mode 100644 roi-selector/src/components/RoiCoordinateFields.tsx create mode 100644 roi-selector/src/components/RoiDrawControls.tsx create mode 100644 roi-selector/src/components/SavedRoiItem.tsx create mode 100644 roi-selector/src/components/SavedRoiList.tsx create mode 100644 roi-selector/src/hooks/useRoiFields.ts diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index c5b57f01..6ed72215 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -1,274 +1,54 @@ -import { ContentCopy, CropFree, Delete, Edit, ExpandMore, HighlightAlt, VisibilityOff, MyLocation, SelectAll } from "@mui/icons-material"; -import { - Box, - Button, - Collapse, - Divider, - Grid, - IconButton, - Snackbar, - TextField, - Tooltip, - Typography, -} from "@mui/material"; -import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import React, { useEffect, useRef, useState } from "react"; - -import { - useViewState, - currentZInfoAtom, - currentImageBoundsAtom, - viewportAtom, - setZSliceAtom, -} from "@biongff/vizarr"; - -import { - roiDrawStateAtom, - savedRoisAtom, - pendingRoiAtom, - normalizeRoiBounds, - clampToBounds, - nextAvailableColor, - type SavedRoi, -} from "./state"; - +import { CropFree } from "@mui/icons-material"; +import { Box, Collapse, IconButton, Snackbar, Tooltip, Typography } from "@mui/material"; +import { useAtomValue, useSetAtom } from "jotai"; +import React, { useState } from "react"; + +import { setZSliceAtom, useViewState, viewportAtom } from "@biongff/vizarr"; + +import RoiCoordinateFields from "./components/RoiCoordinateFields"; +import RoiDrawControls from "./components/RoiDrawControls"; +import SavedRoiList from "./components/SavedRoiList"; +import { useRoiFields } from "./hooks/useRoiFields"; +import { normalizeRoiBounds, type SavedRoi } from "./state"; import { useRoiDeckExtension } from "./useRoiDeckExtension"; /** * RoiSelector — a collapsible panel that lets you: * - * 1. Type in top-left (x₁, y₁) and bottom-right (x₂, y₂) image - * coordinates and zoom the viewer to that bounding box. - * 2. See the coordinates of the region currently in view. - * 3. Copy those coordinates to the clipboard with one click. - * - * How does the zoom math work? - * ---------------------------- - * deck.gl's OrthographicView uses a `viewState` with: - * • `target: [x, y]` — the image coordinate at the center of the viewport - * • `zoom` — a log₂ scale factor (zoom 0 = 1:1 pixels, zoom -1 = 50 %, etc.) + * 1. Draw ROI rectangles directly on the image canvas. + * 2. Type in top-left (x₁, y₁) and bottom-right (x₂, y₂) image coordinates. + * 3. Save, edit, delete, and copy ROIs. + * 4. Navigate the viewer to a saved ROI. * - * Given an ROI defined by (x₁,y₁)–(x₂,y₂) and a viewport of size (W,H): - * target = center of the ROI = [(x₁+x₂)/2 , (y₁+y₂)/2] - * zoom = log₂( min(W / roiWidth, H / roiHeight) ) - * - * This is exactly the same formula used by `fitImageToViewport` in utils.ts, - * except here we apply it to the user-supplied ROI rectangle instead of the - * full image extent. + * State management is handled by `useRoiFields`. + * deck.gl interaction (overlays, clicks) is handled by `useRoiDeckExtension`. + * This component is responsible only for panel layout, navigation, and clipboard. */ function RoiSelector() { - // ---- Register deck.gl extension (overlays, click/hover handlers) ---- useRoiDeckExtension(); - // -------- local UI state -------- + const { + x1, y1, x2, y2, z1, z2, + onX1Change, onY1Change, onX2Change, onY2Change, onZ1Change, onZ2Change, + hasZAxis, zInfo, imageBounds, + isDrawing, roiDrawState, + pendingRoi, savedRois, editingRoiId, + handleToggleDraw, handleSaveRoi, handleDiscardRoi, + handleDeleteRoi, handleToggleVisibility, + handleEditRoi, handleUpdateRoi, handleCancelEdit, + } = useRoiFields(); + + // ---- Panel toggle state ---- const [open, setOpen] = useState(false); const [roiMenuOpen, setRoiMenuOpen] = useState(false); - const [editingRoiId, setEditingRoiId] = useState(null); - - // The four text fields (kept as strings so the user can type freely): - const [x1, setX1] = useState(""); - const [y1, setY1] = useState(""); - const [x2, setX2] = useState(""); - const [y2, setY2] = useState(""); - - // Z-axis slice fields: - const [z1, setZ1] = useState(""); - const [z2, setZ2] = useState(""); - - // Snackbar feedback after clipboard copy: const [snackOpen, setSnackOpen] = useState(false); - // -------- Z-axis info from first layer -------- - const zInfo = useAtomValue(currentZInfoAtom); - const imageBounds = useAtomValue(currentImageBoundsAtom); - const hasZAxis = zInfo !== null; - - // -------- draw-on-image state -------- - // This Jotai atom is shared with . Setting it to "waiting-first" - // tells the Viewer to intercept the next two clicks as ROI corners. - const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); - const isDrawing = roiDrawState !== null; - - // -------- multi-ROI state -------- - const [savedRois, setSavedRois] = useAtom(savedRoisAtom); - const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); - - // Track whether we are the ones updating pendingRoi (to avoid re-sync loop). - const internalUpdate = useRef(false); - - // Fill text fields when a pending ROI is set externally (after second click in Viewer). - useEffect(() => { - if (internalUpdate.current) { - internalUpdate.current = false; - return; - } - if (pendingRoi) { - const bn = normalizeRoiBounds(pendingRoi); - const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; - setX1(String(b.x1)); - setY1(String(b.y1)); - setX2(String(b.x2)); - setY2(String(b.y2)); - setZ1(String(b.z1)); - setZ2(String(b.z2)); - } - }, [pendingRoi]); - - /** - * Sync text field changes back to the atom layer so the overlay updates live. - * - * Uses functional updaters to avoid stale-closure issues: the latest atom - * state is always received via the `prev` argument rather than captured - * from the enclosing render scope. - */ - const syncFieldsToPending = React.useCallback( - (nx1: string, ny1: string, nx2: string, ny2: string, nz1: string, nz2: string) => { - const px1 = Number(nx1); - const py1 = Number(ny1); - const px2 = Number(nx2); - const py2 = Number(ny2); - if ([px1, py1, px2, py2].some(Number.isNaN)) return; - - const newCorner1: [number, number] = [Math.min(px1, px2), Math.min(py1, py2)]; - const newCorner2: [number, number] = [Math.max(px1, px2), Math.max(py1, py2)]; - - // When editing a saved ROI, update it in-place for live overlay feedback. - if (editingRoiId) { - setSavedRois((prev) => - prev.map((r) => { - if (r.id !== editingRoiId) return r; - return { - ...r, - corner1: newCorner1, - corner2: newCorner2, - z1: nz1 !== "" ? Number(nz1) : r.z1, - z2: nz2 !== "" ? Number(nz2) : r.z2, - }; - }), - ); - return; - } - - // Update the pending ROI using a functional updater so we never - // read a stale `pendingRoi` from the closure. - setPendingRoi((prev) => { - if (!prev) return prev; - internalUpdate.current = true; - return { - corner1: newCorner1, - corner2: newCorner2, - z1: nz1 !== "" ? Number(nz1) : prev.z1, - z2: nz2 !== "" ? Number(nz2) : prev.z2, - }; - }); - }, - [editingRoiId, setSavedRois, setPendingRoi], - ); - - // Field change helpers — update local state AND sync to pending overlay. - const onX1Change = (v: string) => { setX1(v); syncFieldsToPending(v, y1, x2, y2, z1, z2); }; - const onY1Change = (v: string) => { setY1(v); syncFieldsToPending(x1, v, x2, y2, z1, z2); }; - const onX2Change = (v: string) => { setX2(v); syncFieldsToPending(x1, y1, v, y2, z1, z2); }; - const onY2Change = (v: string) => { setY2(v); syncFieldsToPending(x1, y1, x2, v, z1, z2); }; - const onZ1Change = (v: string) => { setZ1(v); syncFieldsToPending(x1, y1, x2, y2, v, z2); }; - const onZ2Change = (v: string) => { setZ2(v); syncFieldsToPending(x1, y1, x2, y2, z1, v); }; - - /** - * Toggle draw mode on/off. - * When activated, the cursor becomes a crosshair and the next two clicks - * on the image will define the ROI corners. - */ - const handleToggleDraw = () => { - if (isDrawing) { - setRoiDrawState(null); // cancel - } else { - setRoiDrawState("waiting-first"); - } - }; - - /** Save the pending ROI to the saved list, using the (possibly adjusted) text field values. */ - const handleSaveRoi = () => { - if (!pendingRoi) return; - const nx1 = Number(x1); - const ny1 = Number(y1); - const nx2 = Number(x2); - const ny2 = Number(y2); - if ([nx1, ny1, nx2, ny2].some(Number.isNaN)) return; - const nz1 = z1 !== "" ? Number(z1) : pendingRoi.z1; - const nz2 = z2 !== "" ? Number(z2) : pendingRoi.z2; - const raw = { corner1: [nx1, ny1] as [number, number], corner2: [nx2, ny2] as [number, number], z1: nz1, z2: nz2 }; - const b = imageBounds ? clampToBounds(normalizeRoiBounds(raw), imageBounds) : normalizeRoiBounds(raw); - setSavedRois((prev) => [ - ...prev, - { - id: Math.random().toString(36).slice(2), - corner1: [b.x1, b.y1], - corner2: [b.x2, b.y2], - z1: b.z1, - z2: b.z2, - color: nextAvailableColor(prev), - visible: true, - }, - ]); - setPendingRoi(null); - }; - - /** Discard the pending ROI without saving. */ - const handleDiscardRoi = () => { - setPendingRoi(null); - }; - - /** Delete a saved ROI by id. */ - const handleDeleteRoi = (id: string) => { - setSavedRois((prev) => prev.filter((r) => r.id !== id)); - if (editingRoiId === id) setEditingRoiId(null); - }; - - /** Toggle visibility of a saved ROI. */ - const handleToggleVisibility = (id: string) => { - setSavedRois((prev) => prev.map((r) => (r.id === id ? { ...r, visible: !r.visible } : r))); - }; - - /** Stash the original values when entering edit mode so we can restore on cancel. */ - const editOriginal = useRef(null); - - /** Enter edit mode for a saved ROI: populate fields and track the id. */ - const handleEditRoi = (roi: SavedRoi) => { - // Cancel any in-progress drawing or pending ROI - if (pendingRoi) setPendingRoi(null); - if (isDrawing) setRoiDrawState(null); - editOriginal.current = { ...roi }; - setEditingRoiId(roi.id); - const bn = normalizeRoiBounds(roi); - const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; - setX1(String(b.x1)); - setY1(String(b.y1)); - setX2(String(b.x2)); - setY2(String(b.y2)); - setZ1(String(b.z1)); - setZ2(String(b.z2)); - }; - - /** Finish editing: commit current field values (already live-synced) and exit edit mode. */ - const handleUpdateRoi = () => { - editOriginal.current = null; - setEditingRoiId(null); - }; - - /** Cancel editing: restore the saved ROI to its original values. */ - const handleCancelEdit = () => { - if (editOriginal.current && editingRoiId) { - const orig = editOriginal.current; - setSavedRois((prev) => prev.map((r) => (r.id === editingRoiId ? orig : r))); - } - editOriginal.current = null; - setEditingRoiId(null); - }; - - // Write-only atom to change the Z slice for all sources. + // ---- Viewer navigation ---- + const [, setViewState] = useViewState(); + const viewport = useAtomValue(viewportAtom); const setZSlice = useSetAtom(setZSliceAtom); - /** Navigate the viewer to a specific saved ROI (XY + Z) and ensure visibility. */ + /** Navigate the viewer to a saved ROI (XY + Z) and make it visible. */ const handleGoToSavedRoi = (roi: SavedRoi) => { if (!viewport) return; const b = normalizeRoiBounds(roi); @@ -276,61 +56,44 @@ function RoiSelector() { const roiHeight = b.y2 - b.y1; if (roiWidth === 0 || roiHeight === 0) return; const padding = 40; - const availableWidth = viewport.width - 2 * padding; - const availableHeight = viewport.height - 2 * padding; - const zoom = Math.log2(Math.min(availableWidth / roiWidth, availableHeight / roiHeight)); + const zoom = Math.log2( + Math.min( + (viewport.width - 2 * padding) / roiWidth, + (viewport.height - 2 * padding) / roiHeight, + ), + ); setViewState({ zoom, target: [(b.x1 + b.x2) / 2, (b.y1 + b.y2) / 2], width: viewport.width, height: viewport.height, }); - // Navigate to the ROI's Z plane (use the start of its range) - if (hasZAxis) { - setZSlice(b.z1); - } - // Ensure the ROI is visible + if (hasZAxis) setZSlice(b.z1); if (!roi.visible) { - setSavedRois((prev) => prev.map((r) => (r.id === roi.id ? { ...r, visible: true } : r))); + // reuse the atom setter via handleToggleVisibility — ROI is currently hidden + handleToggleVisibility(roi.id); } }; - /** Build a clipboard payload from a ROI. */ + // ---- Clipboard ---- const roiToPayload = (roi: SavedRoi): Record => { const b = normalizeRoiBounds(roi); const payload: Record = { x1: b.x1, y1: b.y1, x2: b.x2, y2: b.y2 }; - if (hasZAxis) { - payload.z1 = b.z1; - payload.z2 = b.z2; - } + if (hasZAxis) { payload.z1 = b.z1; payload.z2 = b.z2; } return payload; }; - /** Copy a single ROI's coordinates to clipboard. */ const handleCopySingleRoi = (roi: SavedRoi) => { navigator.clipboard.writeText(JSON.stringify(roiToPayload(roi))).then(() => setSnackOpen(true)); }; - /** Copy all saved ROIs to clipboard as a JSON array. */ const handleCopyAllRois = () => { - const arr = savedRois.map(roiToPayload); - navigator.clipboard.writeText(JSON.stringify(arr, null, 2)).then(() => setSnackOpen(true)); + navigator.clipboard + .writeText(JSON.stringify(savedRois.map(roiToPayload), null, 2)) + .then(() => setSnackOpen(true)); }; - // -------- shared viewer state -------- - // `setViewState` lets us drive the camera programmatically. - const [, setViewState] = useViewState(); - - // `viewport` gives us the pixel dimensions of the deck.gl canvas so we know - // how large the browser window is — needed to compute the zoom level. - const viewport = useAtomValue(viewportAtom); - - // -------- handlers -------- - - - - // -------- render -------- - + // ---- Render ---- return ( - {/* Toggle button */} - {/* Collapsible panel */} - {/* ---- Coordinate fields (shown when there is a pending ROI or editing a saved ROI) ---- */} {(pendingRoi || editingRoiId) && ( - <> - {/* ---- Top-left coordinate ---- */} - - Top-left (x₁, y₁) - - - - onX1Change(e.target.value)} - fullWidth - slotProps={{ - input: { sx: { color: "#fff", fontSize: 12 } }, - htmlInput: { min: 0, max: imageBounds?.xMax }, - }} - /> - - - onY1Change(e.target.value)} - fullWidth - slotProps={{ - input: { sx: { color: "#fff", fontSize: 12 } }, - htmlInput: { min: 0, max: imageBounds?.yMax }, - }} - /> - - - - {/* ---- Bottom-right coordinate ---- */} - - Bottom-right (x₂, y₂) - - - - onX2Change(e.target.value)} - fullWidth - slotProps={{ - input: { sx: { color: "#fff", fontSize: 12 } }, - htmlInput: { min: 0, max: imageBounds?.xMax }, - }} - /> - - - onY2Change(e.target.value)} - fullWidth - slotProps={{ - input: { sx: { color: "#fff", fontSize: 12 } }, - htmlInput: { min: 0, max: imageBounds?.yMax }, - }} - /> - - - - {/* ---- Z-axis range (only shown when data has a Z axis) ---- */} - {hasZAxis && ( - <> - - Z range (slice) - - - - onZ1Change(e.target.value)} - fullWidth - slotProps={{ - input: { sx: { color: "#fff", fontSize: 12 } }, - htmlInput: { min: 0, max: zInfo.zMax }, - }} - /> - - - onZ2Change(e.target.value)} - fullWidth - slotProps={{ - input: { sx: { color: "#fff", fontSize: 12 } }, - htmlInput: { min: 0, max: zInfo.zMax }, - }} - /> - - - + )} - - )} - - {/* ---- Draw / Save / Discard / Update / Cancel buttons ---- */} - {editingRoiId ? ( - - - - - - - - - ) : pendingRoi ? ( - - - - - - - - - ) : ( - - )} - - {/* ---- Saved ROIs (collapsible menu) ---- */} - {savedRois.length > 0 && ( - <> - - setRoiMenuOpen((prev) => !prev)} - sx={{ - cursor: "pointer", - display: "flex", - alignItems: "center", - "&:hover": { opacity: 0.8 }, - }} - > - - - Saved ROIs ({savedRois.length}) - - - - - {savedRois.map((roi) => { - const b = normalizeRoiBounds(roi); - return ( - - {/* Color dot – click to toggle visibility */} - - handleToggleVisibility(roi.id)} - sx={{ p: 0, mr: 0.5, flexShrink: 0, width: 16, height: 16, minWidth: 0 }} - > - {roi.visible ? ( - - ) : ( - - )} - - - {/* Coordinates + Z info */} - - - ({b.x1}, {b.y1}) → ({b.x2}, {b.y2}) - - {hasZAxis && ( - - z: {b.z1 === b.z2 ? b.z1 : `${b.z1}–${b.z2}`} - - )} - - {/* Action icons */} - - handleGoToSavedRoi(roi)} - sx={{ color: "grey.400", p: 0.25 }} - > - - - - - handleCopySingleRoi(roi)} - sx={{ color: "grey.400", p: 0.25 }} - > - - - - - - handleEditRoi(roi)} - sx={{ color: editingRoiId === roi.id ? "primary.main" : "grey.400", p: 0.25 }} - > - - - - - handleDeleteRoi(roi.id)} - sx={{ color: "grey.500", p: 0.25 }} - > - - - - - ); - })} - {/* Copy All button */} - - - - - )} - + + + setRoiMenuOpen((prev) => !prev)} + onToggleVisibility={handleToggleVisibility} + onGoTo={handleGoToSavedRoi} + onCopy={handleCopySingleRoi} + onEdit={handleEditRoi} + onDelete={handleDeleteRoi} + onCopyAll={handleCopyAllRois} + /> - {/* Snackbar shown briefly after a successful copy */} void; + onY1Change: (v: string) => void; + onX2Change: (v: string) => void; + onY2Change: (v: string) => void; + onZ1Change: (v: string) => void; + onZ2Change: (v: string) => void; + hasZAxis: boolean; + zInfo: { zMax: number } | null; + imageBounds: ImageBounds | null; +} + +const fieldSx = { color: "#fff", fontSize: 12 }; + +export default function RoiCoordinateFields({ + x1, y1, x2, y2, z1, z2, + onX1Change, onY1Change, onX2Change, onY2Change, onZ1Change, onZ2Change, + hasZAxis, zInfo, imageBounds, +}: RoiCoordinateFieldsProps) { + return ( + <> + {/* ---- Top-left ---- */} + + Top-left (x₁, y₁) + + + + onX1Change(e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: imageBounds?.xMax }, + }} + /> + + + onY1Change(e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: imageBounds?.yMax }, + }} + /> + + + + {/* ---- Bottom-right ---- */} + + Bottom-right (x₂, y₂) + + + + onX2Change(e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: imageBounds?.xMax }, + }} + /> + + + onY2Change(e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: imageBounds?.yMax }, + }} + /> + + + + {/* ---- Z range (only when data has a Z axis) ---- */} + {hasZAxis && zInfo && ( + <> + + Z range (slice) + + + + onZ1Change(e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: zInfo.zMax }, + }} + /> + + + onZ2Change(e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: zInfo.zMax }, + }} + /> + + + + )} + + ); +} diff --git a/roi-selector/src/components/RoiDrawControls.tsx b/roi-selector/src/components/RoiDrawControls.tsx new file mode 100644 index 00000000..be0d8533 --- /dev/null +++ b/roi-selector/src/components/RoiDrawControls.tsx @@ -0,0 +1,111 @@ +import { HighlightAlt } from "@mui/icons-material"; +import { Button, Grid } from "@mui/material"; +import React from "react"; + +import type { PendingRoi, RoiDrawState } from "../state"; + +interface RoiDrawControlsProps { + editingRoiId: string | null; + pendingRoi: PendingRoi | null; + isDrawing: boolean; + roiDrawState: RoiDrawState; + onToggleDraw: () => void; + onSave: () => void; + onDiscard: () => void; + onUpdate: () => void; + onCancelEdit: () => void; +} + +const btnSx = { textTransform: "none" as const, fontSize: 11 }; + +export default function RoiDrawControls({ + editingRoiId, + pendingRoi, + isDrawing, + roiDrawState, + onToggleDraw, + onSave, + onDiscard, + onUpdate, + onCancelEdit, +}: RoiDrawControlsProps) { + if (editingRoiId) { + return ( + + + + + + + + + ); + } + + if (pendingRoi) { + return ( + + + + + + + + + ); + } + + return ( + + ); +} diff --git a/roi-selector/src/components/SavedRoiItem.tsx b/roi-selector/src/components/SavedRoiItem.tsx new file mode 100644 index 00000000..ab21f2d3 --- /dev/null +++ b/roi-selector/src/components/SavedRoiItem.tsx @@ -0,0 +1,116 @@ +import { ContentCopy, Delete, Edit, MyLocation, VisibilityOff } from "@mui/icons-material"; +import { Box, IconButton, Tooltip, Typography } from "@mui/material"; +import React from "react"; + +import { normalizeRoiBounds, type SavedRoi } from "../state"; + +interface SavedRoiItemProps { + roi: SavedRoi; + hasZAxis: boolean; + isEditing: boolean; + onToggleVisibility: () => void; + onGoTo: () => void; + onCopy: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export default function SavedRoiItem({ + roi, + hasZAxis, + isEditing, + onToggleVisibility, + onGoTo, + onCopy, + onEdit, + onDelete, +}: SavedRoiItemProps) { + const b = normalizeRoiBounds(roi); + + return ( + + {/* Color dot — click to toggle visibility */} + + + {roi.visible ? ( + + ) : ( + + )} + + + + {/* Coordinates + Z info */} + + + ({b.x1}, {b.y1}) → ({b.x2}, {b.y2}) + + {hasZAxis && ( + + z: {b.z1 === b.z2 ? b.z1 : `${b.z1}–${b.z2}`} + + )} + + + {/* Action icons */} + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/roi-selector/src/components/SavedRoiList.tsx b/roi-selector/src/components/SavedRoiList.tsx new file mode 100644 index 00000000..a42f7567 --- /dev/null +++ b/roi-selector/src/components/SavedRoiList.tsx @@ -0,0 +1,101 @@ +import { SelectAll, ExpandMore } from "@mui/icons-material"; +import { Box, Button, Collapse, Divider, Typography } from "@mui/material"; +import React from "react"; + +import type { SavedRoi } from "../state"; +import SavedRoiItem from "./SavedRoiItem"; + +interface SavedRoiListProps { + savedRois: SavedRoi[]; + hasZAxis: boolean; + editingRoiId: string | null; + roiMenuOpen: boolean; + onToggleOpen: () => void; + onToggleVisibility: (id: string) => void; + onGoTo: (roi: SavedRoi) => void; + onCopy: (roi: SavedRoi) => void; + onEdit: (roi: SavedRoi) => void; + onDelete: (id: string) => void; + onCopyAll: () => void; +} + +export default function SavedRoiList({ + savedRois, + hasZAxis, + editingRoiId, + roiMenuOpen, + onToggleOpen, + onToggleVisibility, + onGoTo, + onCopy, + onEdit, + onDelete, + onCopyAll, +}: SavedRoiListProps) { + if (savedRois.length === 0) return null; + + return ( + <> + + + {/* Collapsible header */} + + + + Saved ROIs ({savedRois.length}) + + + + + + {savedRois.map((roi) => ( + onToggleVisibility(roi.id)} + onGoTo={() => onGoTo(roi)} + onCopy={() => onCopy(roi)} + onEdit={() => onEdit(roi)} + onDelete={() => onDelete(roi.id)} + /> + ))} + + + + + + ); +} diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts new file mode 100644 index 00000000..4ebfe8d4 --- /dev/null +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -0,0 +1,257 @@ +import { useAtom, useAtomValue } from "jotai"; +import React, { useEffect, useRef, useState } from "react"; + +import { currentImageBoundsAtom, currentZInfoAtom } from "@biongff/vizarr"; + +import { + clampToBounds, + nextAvailableColor, + normalizeRoiBounds, + pendingRoiAtom, + roiDrawStateAtom, + savedRoisAtom, + type ImageBounds, + type PendingRoi, + type RoiDrawState, + type SavedRoi, +} from "../state"; + +export interface UseRoiFieldsReturn { + // Coordinate field string values (kept as strings so the user can type freely) + x1: string; + y1: string; + x2: string; + y2: string; + z1: string; + z2: string; + // Per-field change handlers + onX1Change: (v: string) => void; + onY1Change: (v: string) => void; + onX2Change: (v: string) => void; + onY2Change: (v: string) => void; + onZ1Change: (v: string) => void; + onZ2Change: (v: string) => void; + // Derived / shared state + hasZAxis: boolean; + zInfo: { zValue: number; zMax: number } | null; + imageBounds: ImageBounds | null; + isDrawing: boolean; + roiDrawState: RoiDrawState; + pendingRoi: PendingRoi | null; + savedRois: SavedRoi[]; + editingRoiId: string | null; + // Handlers + handleToggleDraw: () => void; + handleSaveRoi: () => void; + handleDiscardRoi: () => void; + handleDeleteRoi: (id: string) => void; + handleToggleVisibility: (id: string) => void; + handleEditRoi: (roi: SavedRoi) => void; + handleUpdateRoi: () => void; + handleCancelEdit: () => void; +} + +/** + * Manages all ROI coordinate field state, draw-mode state, and the full + * save / edit / delete / discard lifecycle. + * + * Navigation (zoom to ROI) and clipboard are intentionally left in the + * parent component because they depend on viewer state and the `hasZAxis` + * display flag that is already computed here and forwarded. + */ +export function useRoiFields(): UseRoiFieldsReturn { + const [x1, setX1] = useState(""); + const [y1, setY1] = useState(""); + const [x2, setX2] = useState(""); + const [y2, setY2] = useState(""); + const [z1, setZ1] = useState(""); + const [z2, setZ2] = useState(""); + + const [editingRoiId, setEditingRoiId] = useState(null); + + const zInfo = useAtomValue(currentZInfoAtom); + const imageBounds = useAtomValue(currentImageBoundsAtom); + const hasZAxis = zInfo !== null; + + const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); + const isDrawing = roiDrawState !== null; + + const [savedRois, setSavedRois] = useAtom(savedRoisAtom); + const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); + + // Prevents the pendingRoi → fields effect from re-running when we are the ones + // writing to pendingRoi (e.g. while the user types in the fields). + const internalUpdate = useRef(false); + + // Stash the original ROI values when entering edit mode so cancel can restore them. + const editOriginal = useRef(null); + + // ---- Populate fields from external pendingRoi changes (draw-on-canvas) ---- + useEffect(() => { + if (internalUpdate.current) { + internalUpdate.current = false; + return; + } + if (pendingRoi) { + const bn = normalizeRoiBounds(pendingRoi); + const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; + setX1(String(b.x1)); + setY1(String(b.y1)); + setX2(String(b.x2)); + setY2(String(b.y2)); + setZ1(String(b.z1)); + setZ2(String(b.z2)); + } + }, [pendingRoi, imageBounds]); + + // ---- Live sync: field changes → atom (for overlay preview) ---- + const syncFieldsToPending = React.useCallback( + (nx1: string, ny1: string, nx2: string, ny2: string, nz1: string, nz2: string) => { + const px1 = Number(nx1); + const py1 = Number(ny1); + const px2 = Number(nx2); + const py2 = Number(ny2); + if ([px1, py1, px2, py2].some(Number.isNaN)) return; + + const newCorner1: [number, number] = [Math.min(px1, px2), Math.min(py1, py2)]; + const newCorner2: [number, number] = [Math.max(px1, px2), Math.max(py1, py2)]; + + if (editingRoiId) { + setSavedRois((prev) => + prev.map((r) => { + if (r.id !== editingRoiId) return r; + return { + ...r, + corner1: newCorner1, + corner2: newCorner2, + z1: nz1 !== "" ? Number(nz1) : r.z1, + z2: nz2 !== "" ? Number(nz2) : r.z2, + }; + }), + ); + return; + } + + setPendingRoi((prev) => { + if (!prev) return prev; + internalUpdate.current = true; + return { + corner1: newCorner1, + corner2: newCorner2, + z1: nz1 !== "" ? Number(nz1) : prev.z1, + z2: nz2 !== "" ? Number(nz2) : prev.z2, + }; + }); + }, + [editingRoiId, setSavedRois, setPendingRoi], + ); + + const onX1Change = (v: string) => { setX1(v); syncFieldsToPending(v, y1, x2, y2, z1, z2); }; + const onY1Change = (v: string) => { setY1(v); syncFieldsToPending(x1, v, x2, y2, z1, z2); }; + const onX2Change = (v: string) => { setX2(v); syncFieldsToPending(x1, y1, v, y2, z1, z2); }; + const onY2Change = (v: string) => { setY2(v); syncFieldsToPending(x1, y1, x2, v, z1, z2); }; + const onZ1Change = (v: string) => { setZ1(v); syncFieldsToPending(x1, y1, x2, y2, v, z2); }; + const onZ2Change = (v: string) => { setZ2(v); syncFieldsToPending(x1, y1, x2, y2, z1, v); }; + + // ---- Draw-mode toggle ---- + const handleToggleDraw = () => { + if (isDrawing) { + setRoiDrawState(null); + } else { + setRoiDrawState("waiting-first"); + } + }; + + // ---- Save pending ROI ---- + const handleSaveRoi = () => { + if (!pendingRoi) return; + const nx1 = Number(x1); + const ny1 = Number(y1); + const nx2 = Number(x2); + const ny2 = Number(y2); + if ([nx1, ny1, nx2, ny2].some(Number.isNaN)) return; + const nz1 = z1 !== "" ? Number(z1) : pendingRoi.z1; + const nz2 = z2 !== "" ? Number(z2) : pendingRoi.z2; + const raw = { + corner1: [nx1, ny1] as [number, number], + corner2: [nx2, ny2] as [number, number], + z1: nz1, + z2: nz2, + }; + const b = imageBounds ? clampToBounds(normalizeRoiBounds(raw), imageBounds) : normalizeRoiBounds(raw); + setSavedRois((prev) => [ + ...prev, + { + id: Math.random().toString(36).slice(2), + corner1: [b.x1, b.y1], + corner2: [b.x2, b.y2], + z1: b.z1, + z2: b.z2, + color: nextAvailableColor(prev), + visible: true, + }, + ]); + setPendingRoi(null); + }; + + const handleDiscardRoi = () => setPendingRoi(null); + + const handleDeleteRoi = (id: string) => { + setSavedRois((prev) => prev.filter((r) => r.id !== id)); + if (editingRoiId === id) setEditingRoiId(null); + }; + + const handleToggleVisibility = (id: string) => { + setSavedRois((prev) => prev.map((r) => (r.id === id ? { ...r, visible: !r.visible } : r))); + }; + + const handleEditRoi = (roi: SavedRoi) => { + if (pendingRoi) setPendingRoi(null); + if (isDrawing) setRoiDrawState(null); + editOriginal.current = { ...roi }; + setEditingRoiId(roi.id); + const bn = normalizeRoiBounds(roi); + const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; + setX1(String(b.x1)); + setY1(String(b.y1)); + setX2(String(b.x2)); + setY2(String(b.y2)); + setZ1(String(b.z1)); + setZ2(String(b.z2)); + }; + + const handleUpdateRoi = () => { + editOriginal.current = null; + setEditingRoiId(null); + }; + + const handleCancelEdit = () => { + if (editOriginal.current && editingRoiId) { + const orig = editOriginal.current; + setSavedRois((prev) => prev.map((r) => (r.id === editingRoiId ? orig : r))); + } + editOriginal.current = null; + setEditingRoiId(null); + }; + + return { + x1, y1, x2, y2, z1, z2, + onX1Change, onY1Change, onX2Change, onY2Change, onZ1Change, onZ2Change, + hasZAxis, + zInfo, + imageBounds, + isDrawing, + roiDrawState, + pendingRoi, + savedRois, + editingRoiId, + handleToggleDraw, + handleSaveRoi, + handleDiscardRoi, + handleDeleteRoi, + handleToggleVisibility, + handleEditRoi, + handleUpdateRoi, + handleCancelEdit, + }; +} diff --git a/viewer/src/components/Viewer.tsx b/viewer/src/components/Viewer.tsx index cfd0df19..25035155 100644 --- a/viewer/src/components/Viewer.tsx +++ b/viewer/src/components/Viewer.tsx @@ -18,7 +18,8 @@ export default function Viewer() { const [viewState, setViewState] = useViewState(); const layers = useAtomValue(layerAtoms); const firstLayer = layers[0] as VizarrLayer; - const axisNavigationSnackbar = useAxisNavigation(deckRef, viewport); + + const axisNavigationSnackbar = useAxisNavigation(deckRef); // ---- Plugin extension system ---- const extensions = useAtomValue(deckExtensionsAtom); @@ -44,7 +45,8 @@ export default function Viewer() { React.useEffect(() => { if (!viewport && deckRef.current?.deck) { - setViewport(deckRef.current.deck); + const d = deckRef.current.deck; + setViewport({ width: d.width, height: d.height }); } if (viewport && firstLayer) { if (!viewState) { @@ -209,7 +211,10 @@ export default function Viewer() { onClick={handleClick} onHover={handleHover} getCursor={getCursor} - onDeviceInitialized={() => setViewport(deckRef.current?.deck || null)} + onDeviceInitialized={() => { + const d = deckRef.current?.deck; + setViewport(d ? { width: d.width, height: d.height } : null); + }} /> {axisNavigationSnackbar} diff --git a/viewer/src/hooks/useAxisNavigation.tsx b/viewer/src/hooks/useAxisNavigation.tsx index 635e0ca0..c6cd54ce 100644 --- a/viewer/src/hooks/useAxisNavigation.tsx +++ b/viewer/src/hooks/useAxisNavigation.tsx @@ -5,8 +5,6 @@ import { useAtomCallback } from "jotai/utils"; import * as React from "react"; import { layerFamilyAtom, sourceInfoAtom } from "../state"; -type DeckInstance = DeckGLRef["deck"] | null; - type Axis = "z" | "t"; type AdjustArgs = { axis: Axis; @@ -33,7 +31,7 @@ function AxisNavigationSnackbar({ open, axis }: { open: boolean; axis: string }) ); } -export function useAxisNavigation(deckRef: React.RefObject, viewport: DeckInstance) { +export function useAxisNavigation(deckRef: React.RefObject) { const [axisScrollKey, setAxisScrollKey] = React.useState(null); const axisScrollKeyRef = React.useRef(null); const axisScrollAccumulatorRef = React.useRef(0); @@ -52,7 +50,7 @@ export function useAxisNavigation(deckRef: React.RefObject, viewport: return; } - const deckInstance = viewport ?? deckRef.current?.deck ?? null; + const deckInstance = deckRef.current?.deck ?? null; const canvas = (deckInstance as { canvas?: HTMLCanvasElement } | null)?.canvas; if (!deckInstance || !canvas) { return; // no deck instance or canvas @@ -190,7 +188,7 @@ export function useAxisNavigation(deckRef: React.RefObject, viewport: }), ); }, - [viewport, deckRef], + [deckRef], ), ); @@ -264,7 +262,7 @@ export function useAxisNavigation(deckRef: React.RefObject, viewport: return; // ignore if no axis key is set, fall back to default zoom behavior } - const deckInstance = viewport ?? deckRef.current?.deck ?? null; + const deckInstance = deckRef.current?.deck ?? null; const canvas = (deckInstance as { canvas?: HTMLCanvasElement } | null)?.canvas; if (!deckInstance || !canvas) { return; // no deck instance or canvas @@ -291,12 +289,12 @@ export function useAxisNavigation(deckRef: React.RefObject, viewport: const pointer = { x, y }; adjustAxis({ axis: axisScrollKey, delta: -steps, pointer }); }, - [axisScrollKey, viewport, deckRef, adjustAxis], + [axisScrollKey, deckRef, adjustAxis], ); React.useEffect(() => { // attach wheel listener to deck canvas - const deckInstance = (viewport ?? deckRef.current?.deck ?? null) as { canvas?: HTMLCanvasElement } | null; + const deckInstance = (deckRef.current?.deck ?? null) as { canvas?: HTMLCanvasElement } | null; const element = deckInstance?.canvas; if (!element) { return; @@ -310,7 +308,7 @@ export function useAxisNavigation(deckRef: React.RefObject, viewport: return () => { element.removeEventListener("wheel", listener); }; - }, [viewport, handleWheel, deckRef]); + }, [handleWheel, deckRef]); // @TODO: check axis is present return axisScrollKey !== null && ; diff --git a/viewer/src/index.tsx b/viewer/src/index.tsx index f46e3d7e..39ef8ed6 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -11,6 +11,7 @@ export type { ViewState, ImageLayerConfig } from "./state"; // Plugin extension system export { deckExtensionsAtom, viewportAtom } from "./state"; +export type { ViewportSize } from "./state"; export type { DeckExtension, OverlayPolygon } from "./state"; // Z-axis and image-bounds utilities diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 81635894..225b295c 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -2,7 +2,13 @@ import { type Atom, atom } from "jotai"; import { atomFamily, splitAtom, waitForAll } from "jotai/utils"; import { RedirectError, rethrowUnless } from "./utils"; -import type { Deck, Layer } from "deck.gl"; +import type { Layer } from "deck.gl"; + +/** Plain-data snapshot of the deck.gl canvas dimensions. */ +export interface ViewportSize { + width: number; + height: number; +} import type { PrimitiveAtom } from "jotai"; import type { AtomFamily } from "jotai/vanilla/utils/atomFamily"; import type { Matrix4 } from "math.gl"; @@ -210,7 +216,7 @@ export interface Redirect { } export const redirectObjAtom = atom(null); -export const viewportAtom = atom(null); +export const viewportAtom = atom(null); export const sourceInfoAtom = atom[]>([]); From 4048192c8efdedf0eb7a318190346f0cb4d2b029 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 13:36:17 +0100 Subject: [PATCH 07/32] fix: correct go to roi bugs --- roi-selector/src/RoiSelector.tsx | 7 ++++++- viewer/src/components/Viewer.tsx | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 6ed72215..7730357b 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -68,7 +68,12 @@ function RoiSelector() { width: viewport.width, height: viewport.height, }); - if (hasZAxis) setZSlice(b.z1); + if (hasZAxis && zInfo) { + // Only jump Z if the current slice is outside the ROI's Z range. + if (zInfo.zValue < b.z1 || zInfo.zValue > b.z2) { + setZSlice(b.z1); + } + } if (!roi.visible) { // reuse the atom setter via handleToggleVisibility — ROI is currently hidden handleToggleVisibility(roi.id); diff --git a/viewer/src/components/Viewer.tsx b/viewer/src/components/Viewer.tsx index 25035155..20768770 100644 --- a/viewer/src/components/Viewer.tsx +++ b/viewer/src/components/Viewer.tsx @@ -215,6 +215,9 @@ export default function Viewer() { const d = deckRef.current?.deck; setViewport(d ? { width: d.width, height: d.height } : null); }} + onResize={({ width, height }: { width: number; height: number }) => + setViewport({ width, height }) + } /> {axisNavigationSnackbar} From ebbb9f1abfc6fab508566b466fcaf61e1bf8d5a6 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 13:36:17 +0100 Subject: [PATCH 08/32] refactor: pnpm fix before PR --- roi-selector/src/RoiSelector.tsx | 65 ++++++++++++------- .../src/components/RoiCoordinateFields.tsx | 18 ++++- .../src/components/RoiDrawControls.tsx | 42 ++---------- roi-selector/src/components/SavedRoiItem.tsx | 13 +--- roi-selector/src/components/SavedRoiList.tsx | 2 +- roi-selector/src/hooks/useRoiFields.ts | 52 +++++++++++---- roi-selector/src/index.tsx | 10 ++- roi-selector/src/state.ts | 17 +++-- roi-selector/src/useRoiDeckExtension.ts | 46 ++++++------- sites/app/src/App.tsx | 14 ++-- sites/app/vite.config.js | 5 +- viewer/src/components/Viewer.tsx | 10 +-- viewer/src/components/VizarrViewer.tsx | 7 +- 13 files changed, 164 insertions(+), 137 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 7730357b..5f30a90d 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -9,7 +9,7 @@ import RoiCoordinateFields from "./components/RoiCoordinateFields"; import RoiDrawControls from "./components/RoiDrawControls"; import SavedRoiList from "./components/SavedRoiList"; import { useRoiFields } from "./hooks/useRoiFields"; -import { normalizeRoiBounds, type SavedRoi } from "./state"; +import { type SavedRoi, normalizeRoiBounds } from "./state"; import { useRoiDeckExtension } from "./useRoiDeckExtension"; /** @@ -28,14 +28,34 @@ function RoiSelector() { useRoiDeckExtension(); const { - x1, y1, x2, y2, z1, z2, - onX1Change, onY1Change, onX2Change, onY2Change, onZ1Change, onZ2Change, - hasZAxis, zInfo, imageBounds, - isDrawing, roiDrawState, - pendingRoi, savedRois, editingRoiId, - handleToggleDraw, handleSaveRoi, handleDiscardRoi, - handleDeleteRoi, handleToggleVisibility, - handleEditRoi, handleUpdateRoi, handleCancelEdit, + x1, + y1, + x2, + y2, + z1, + z2, + onX1Change, + onY1Change, + onX2Change, + onY2Change, + onZ1Change, + onZ2Change, + hasZAxis, + zInfo, + imageBounds, + isDrawing, + roiDrawState, + pendingRoi, + savedRois, + editingRoiId, + handleToggleDraw, + handleSaveRoi, + handleDiscardRoi, + handleDeleteRoi, + handleToggleVisibility, + handleEditRoi, + handleUpdateRoi, + handleCancelEdit, } = useRoiFields(); // ---- Panel toggle state ---- @@ -57,10 +77,7 @@ function RoiSelector() { if (roiWidth === 0 || roiHeight === 0) return; const padding = 40; const zoom = Math.log2( - Math.min( - (viewport.width - 2 * padding) / roiWidth, - (viewport.height - 2 * padding) / roiHeight, - ), + Math.min((viewport.width - 2 * padding) / roiWidth, (viewport.height - 2 * padding) / roiHeight), ); setViewState({ zoom, @@ -84,7 +101,10 @@ function RoiSelector() { const roiToPayload = (roi: SavedRoi): Record => { const b = normalizeRoiBounds(roi); const payload: Record = { x1: b.x1, y1: b.y1, x2: b.x2, y2: b.y2 }; - if (hasZAxis) { payload.z1 = b.z1; payload.z2 = b.z2; } + if (hasZAxis) { + payload.z1 = b.z1; + payload.z2 = b.z2; + } return payload; }; @@ -93,9 +113,7 @@ function RoiSelector() { }; const handleCopyAllRois = () => { - navigator.clipboard - .writeText(JSON.stringify(savedRois.map(roiToPayload), null, 2)) - .then(() => setSnackOpen(true)); + navigator.clipboard.writeText(JSON.stringify(savedRois.map(roiToPayload), null, 2)).then(() => setSnackOpen(true)); }; // ---- Render ---- @@ -113,11 +131,7 @@ function RoiSelector() { }} > - setOpen((prev) => !prev)} - sx={{ color: "#fff" }} - > + setOpen((prev) => !prev)} sx={{ color: "#fff" }}> ROI Selection @@ -129,7 +143,12 @@ function RoiSelector() { {(pendingRoi || editingRoiId) && ( diff --git a/roi-selector/src/components/RoiDrawControls.tsx b/roi-selector/src/components/RoiDrawControls.tsx index be0d8533..6e47eeb7 100644 --- a/roi-selector/src/components/RoiDrawControls.tsx +++ b/roi-selector/src/components/RoiDrawControls.tsx @@ -33,26 +33,12 @@ export default function RoiDrawControls({ return ( - - @@ -64,26 +50,12 @@ export default function RoiDrawControls({ return ( - - @@ -101,11 +73,7 @@ export default function RoiDrawControls({ color={isDrawing ? "warning" : "primary"} sx={{ ...btnSx, mt: 0.5, mb: 0.5 }} > - {isDrawing - ? roiDrawState === "waiting-first" - ? "Click corner 1…" - : "Click corner 2…" - : "Draw on image"} + {isDrawing ? (roiDrawState === "waiting-first" ? "Click corner 1…" : "Click corner 2…") : "Draw on image"} ); } diff --git a/roi-selector/src/components/SavedRoiItem.tsx b/roi-selector/src/components/SavedRoiItem.tsx index ab21f2d3..4b5d7743 100644 --- a/roi-selector/src/components/SavedRoiItem.tsx +++ b/roi-selector/src/components/SavedRoiItem.tsx @@ -2,7 +2,7 @@ import { ContentCopy, Delete, Edit, MyLocation, VisibilityOff } from "@mui/icons import { Box, IconButton, Tooltip, Typography } from "@mui/material"; import React from "react"; -import { normalizeRoiBounds, type SavedRoi } from "../state"; +import { type SavedRoi, normalizeRoiBounds } from "../state"; interface SavedRoiItemProps { roi: SavedRoi; @@ -77,10 +77,7 @@ export default function SavedRoiItem({ ({b.x1}, {b.y1}) → ({b.x2}, {b.y2}) {hasZAxis && ( - + z: {b.z1 === b.z2 ? b.z1 : `${b.z1}–${b.z2}`} )} @@ -98,11 +95,7 @@ export default function SavedRoiItem({ - + diff --git a/roi-selector/src/components/SavedRoiList.tsx b/roi-selector/src/components/SavedRoiList.tsx index a42f7567..0e7583dc 100644 --- a/roi-selector/src/components/SavedRoiList.tsx +++ b/roi-selector/src/components/SavedRoiList.tsx @@ -1,4 +1,4 @@ -import { SelectAll, ExpandMore } from "@mui/icons-material"; +import { ExpandMore, SelectAll } from "@mui/icons-material"; import { Box, Button, Collapse, Divider, Typography } from "@mui/material"; import React from "react"; diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index 4ebfe8d4..f12d8c75 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -4,16 +4,16 @@ import React, { useEffect, useRef, useState } from "react"; import { currentImageBoundsAtom, currentZInfoAtom } from "@biongff/vizarr"; import { + type ImageBounds, + type PendingRoi, + type RoiDrawState, + type SavedRoi, clampToBounds, nextAvailableColor, normalizeRoiBounds, pendingRoiAtom, roiDrawStateAtom, savedRoisAtom, - type ImageBounds, - type PendingRoi, - type RoiDrawState, - type SavedRoi, } from "../state"; export interface UseRoiFieldsReturn { @@ -146,12 +146,30 @@ export function useRoiFields(): UseRoiFieldsReturn { [editingRoiId, setSavedRois, setPendingRoi], ); - const onX1Change = (v: string) => { setX1(v); syncFieldsToPending(v, y1, x2, y2, z1, z2); }; - const onY1Change = (v: string) => { setY1(v); syncFieldsToPending(x1, v, x2, y2, z1, z2); }; - const onX2Change = (v: string) => { setX2(v); syncFieldsToPending(x1, y1, v, y2, z1, z2); }; - const onY2Change = (v: string) => { setY2(v); syncFieldsToPending(x1, y1, x2, v, z1, z2); }; - const onZ1Change = (v: string) => { setZ1(v); syncFieldsToPending(x1, y1, x2, y2, v, z2); }; - const onZ2Change = (v: string) => { setZ2(v); syncFieldsToPending(x1, y1, x2, y2, z1, v); }; + const onX1Change = (v: string) => { + setX1(v); + syncFieldsToPending(v, y1, x2, y2, z1, z2); + }; + const onY1Change = (v: string) => { + setY1(v); + syncFieldsToPending(x1, v, x2, y2, z1, z2); + }; + const onX2Change = (v: string) => { + setX2(v); + syncFieldsToPending(x1, y1, v, y2, z1, z2); + }; + const onY2Change = (v: string) => { + setY2(v); + syncFieldsToPending(x1, y1, x2, v, z1, z2); + }; + const onZ1Change = (v: string) => { + setZ1(v); + syncFieldsToPending(x1, y1, x2, y2, v, z2); + }; + const onZ2Change = (v: string) => { + setZ2(v); + syncFieldsToPending(x1, y1, x2, y2, z1, v); + }; // ---- Draw-mode toggle ---- const handleToggleDraw = () => { @@ -235,8 +253,18 @@ export function useRoiFields(): UseRoiFieldsReturn { }; return { - x1, y1, x2, y2, z1, z2, - onX1Change, onY1Change, onX2Change, onY2Change, onZ1Change, onZ2Change, + x1, + y1, + x2, + y2, + z1, + z2, + onX1Change, + onY1Change, + onX2Change, + onY2Change, + onZ1Change, + onZ2Change, hasZAxis, zInfo, imageBounds, diff --git a/roi-selector/src/index.tsx b/roi-selector/src/index.tsx index b6542f37..61f08b90 100644 --- a/roi-selector/src/index.tsx +++ b/roi-selector/src/index.tsx @@ -1,5 +1,13 @@ export { default as RoiSelector } from "./RoiSelector"; // Re-export ROI state for programmatic access -export { roiDrawStateAtom, savedRoisAtom, pendingRoiAtom, ROI_COLORS, normalizeRoiBounds, nextAvailableColor, clampToBounds } from "./state"; +export { + roiDrawStateAtom, + savedRoisAtom, + pendingRoiAtom, + ROI_COLORS, + normalizeRoiBounds, + nextAvailableColor, + clampToBounds, +} from "./state"; export type { RoiDrawState, SavedRoi, PendingRoi, NormalizedBounds, ImageBounds } from "./state"; diff --git a/roi-selector/src/state.ts b/roi-selector/src/state.ts index c6a4b2ff..5134abb6 100644 --- a/roi-selector/src/state.ts +++ b/roi-selector/src/state.ts @@ -49,7 +49,12 @@ export interface NormalizedBounds { * * Works for both `SavedRoi` and `PendingRoi` obj. */ -export function normalizeRoiBounds(roi: { corner1: [number, number]; corner2: [number, number]; z1: number; z2: number }): NormalizedBounds { +export function normalizeRoiBounds(roi: { + corner1: [number, number]; + corner2: [number, number]; + z1: number; + z2: number; +}): NormalizedBounds { return { x1: Math.min(roi.corner1[0], roi.corner2[0]), y1: Math.min(roi.corner1[1], roi.corner2[1]), @@ -88,16 +93,16 @@ export const ROI_COLORS: [number, number, number][] = [ [255, 100, 100], // red [100, 180, 255], // blue [100, 220, 100], // green - [255, 200, 50], // yellow + [255, 200, 50], // yellow [200, 100, 255], // purple - [255, 150, 50], // orange - [50, 220, 200], // teal + [255, 150, 50], // orange + [50, 220, 200], // teal [255, 100, 200], // pink - [180, 220, 80], // lime + [180, 220, 80], // lime [255, 130, 130], // salmon [130, 130, 255], // periwinkle [255, 180, 180], // light coral - [80, 200, 140], // mint + [80, 200, 140], // mint [220, 160, 255], // lavender [255, 220, 100], // gold [100, 200, 200], // cyan diff --git a/roi-selector/src/useRoiDeckExtension.ts b/roi-selector/src/useRoiDeckExtension.ts index cfb9c7ec..386d5756 100644 --- a/roi-selector/src/useRoiDeckExtension.ts +++ b/roi-selector/src/useRoiDeckExtension.ts @@ -1,20 +1,9 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { - currentZInfoAtom, - currentImageBoundsAtom, - deckExtensionsAtom, - type OverlayPolygon, -} from "@biongff/vizarr"; - -import { - roiDrawStateAtom, - savedRoisAtom, - pendingRoiAtom, - nextAvailableColor, - normalizeRoiBounds, -} from "./state"; +import { type OverlayPolygon, currentImageBoundsAtom, currentZInfoAtom, deckExtensionsAtom } from "@biongff/vizarr"; + +import { nextAvailableColor, normalizeRoiBounds, pendingRoiAtom, roiDrawStateAtom, savedRoisAtom } from "./state"; /** * Hook that registers ROI overlay layers, click and hover handlers @@ -35,9 +24,7 @@ export function useRoiDeckExtension() { const nextRoiColor = nextAvailableColor(savedRois); const roiCorner1 = - roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState - ? roiDrawState.corner1 - : null; + roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState ? roiDrawState.corner1 : null; // Track mouse position for the preview rectangle (only while placing second corner). const [roiMousePos, setRoiMousePos] = useState<[number, number] | null>(null); @@ -59,7 +46,12 @@ export function useRoiDeckExtension() { const [bx, by] = roi.corner2; result.push({ id: `roi-saved-${roi.id}`, - polygon: [[ax, ay], [bx, ay], [bx, by], [ax, by]], + polygon: [ + [ax, ay], + [bx, ay], + [bx, by], + [ax, by], + ], fillColor: [...roi.color, 40], lineColor: [...roi.color, 200], }); @@ -71,7 +63,12 @@ export function useRoiDeckExtension() { const [bx, by] = pendingRoi.corner2; result.push({ id: "roi-pending", - polygon: [[ax, ay], [bx, ay], [bx, by], [ax, by]], + polygon: [ + [ax, ay], + [bx, ay], + [bx, by], + [ax, by], + ], fillColor: [...nextRoiColor, 60], lineColor: [...nextRoiColor, 220], }); @@ -83,7 +80,12 @@ export function useRoiDeckExtension() { const [x2, y2] = roiMousePos; result.push({ id: "roi-preview", - polygon: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]], + polygon: [ + [x1, y1], + [x2, y1], + [x2, y2], + [x1, y2], + ], fillColor: [...nextRoiColor, 40], lineColor: [...nextRoiColor, 200], }); @@ -102,9 +104,7 @@ export function useRoiDeckExtension() { const x = imageBounds ? clampXY(rawX, imageBounds.xMax) : Math.round(rawX); const y = imageBounds ? clampXY(rawY, imageBounds.yMax) : Math.round(rawY); const clampZ = (z: number) => - imageBounds?.zMax !== null && imageBounds?.zMax !== undefined - ? Math.max(0, Math.min(z, imageBounds.zMax)) - : z; + imageBounds?.zMax !== null && imageBounds?.zMax !== undefined ? Math.max(0, Math.min(z, imageBounds.zMax)) : z; if (roiDrawState === "waiting-first") { const z1 = clampZ(zInfo?.zValue ?? 0); diff --git a/sites/app/src/App.tsx b/sites/app/src/App.tsx index 2195a6a1..f9cfedad 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -8,20 +8,16 @@ import * as React from "react"; * optionalDeps Vite plugin), this resolves to `null` and nothing renders. */ const roiPromise: Promise<{ default: React.ComponentType } | null> = import("@biongff/roi-selector") - .then((mod) => - typeof mod.RoiSelector === "function" - ? { default: mod.RoiSelector } - : null, - ) + .then((mod) => (typeof mod.RoiSelector === "function" ? { default: mod.RoiSelector } : null)) .catch(() => null); /** True once we know the plugin is available (resolved at module level). */ let roiAvailable = false; -roiPromise.then((m) => { roiAvailable = m !== null; }); +roiPromise.then((m) => { + roiAvailable = m !== null; +}); -const LazyRoiSelector = React.lazy(() => - roiPromise.then((m) => m ?? { default: (() => null) as unknown as React.FC }), -); +const LazyRoiSelector = React.lazy(() => roiPromise.then((m) => m ?? { default: (() => null) as unknown as React.FC })); function parseViewStateFromUrl(): ViewState | undefined { const url = new URL(window.location.href); diff --git a/sites/app/vite.config.js b/sites/app/vite.config.js index a2fc1c53..3634fc8f 100644 --- a/sites/app/vite.config.js +++ b/sites/app/vite.config.js @@ -32,10 +32,7 @@ export default defineConfig(({ mode }) => { const roiActive = /^\s*-\s*['"]?roi-selector['"]?\s*$/m.test(wsContent); return { - plugins: [ - optionalDeps({ "@biongff/roi-selector": "roi-selector" }), - react(), - ], + plugins: [optionalDeps({ "@biongff/roi-selector": "roi-selector" }), react()], resolve: { alias: { ...(mode === "development" diff --git a/viewer/src/components/Viewer.tsx b/viewer/src/components/Viewer.tsx index 20768770..4ef89a08 100644 --- a/viewer/src/components/Viewer.tsx +++ b/viewer/src/components/Viewer.tsx @@ -5,7 +5,7 @@ import { useAtom, useAtomValue } from "jotai"; import * as React from "react"; import { useViewState } from "../hooks"; import { useAxisNavigation } from "../hooks/useAxisNavigation"; -import { layerAtoms, deckExtensionsAtom, viewportAtom } from "../state"; +import { deckExtensionsAtom, layerAtoms, viewportAtom } from "../state"; import { fitImageToViewport, getLayerSize, resolveLoaderFromLayerProps } from "../utils"; import type { DeckGLRef, OrthographicViewState, PickingInfo } from "deck.gl"; @@ -176,9 +176,7 @@ export default function Viewer() { // ---- Generic hover handler (delegates to registered extensions) ---- const handleHover = React.useCallback( (info: PickingInfo) => { - const coord = info.coordinate - ? ([info.coordinate[0], info.coordinate[1]] as [number, number]) - : null; + const coord = info.coordinate ? ([info.coordinate[0], info.coordinate[1]] as [number, number]) : null; for (const ext of Object.values(extensions)) { ext.onHover?.(coord); } @@ -215,9 +213,7 @@ export default function Viewer() { const d = deckRef.current?.deck; setViewport(d ? { width: d.width, height: d.height } : null); }} - onResize={({ width, height }: { width: number; height: number }) => - setViewport({ width, height }) - } + onResize={({ width, height }: { width: number; height: number }) => setViewport({ width, height })} /> {axisNavigationSnackbar} diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index 956c6043..22362aba 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -27,7 +27,12 @@ export interface VizarrViewerProps { children?: React.ReactNode; } -function VizarrViewerComponent({ sources = [], viewState: initialViewState, onViewStateChange, children }: VizarrViewerProps) { +function VizarrViewerComponent({ + sources = [], + viewState: initialViewState, + onViewStateChange, + children, +}: VizarrViewerProps) { const setSourceInfo = useSetAtom(sourceInfoAtom); const setViewStateAtom = useSetAtom(viewStateAtom); const sourceError = useAtomValue(sourceErrorAtom); From 9b3cd95917f8f03b05a818fb4ea43bab9e3de123 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 15:03:31 +0100 Subject: [PATCH 09/32] refactor+fix: minor refactors/fixes from automatic AI PR review --- roi-selector/src/RoiSelector.tsx | 6 ++--- roi-selector/src/hooks/useRoiFields.ts | 14 +++++++--- sites/app/src/App.tsx | 37 +++++++++++++------------- viewer/src/state.ts | 2 +- 4 files changed, 32 insertions(+), 27 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 5f30a90d..d0d561e6 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -76,9 +76,9 @@ function RoiSelector() { const roiHeight = b.y2 - b.y1; if (roiWidth === 0 || roiHeight === 0) return; const padding = 40; - const zoom = Math.log2( - Math.min((viewport.width - 2 * padding) / roiWidth, (viewport.height - 2 * padding) / roiHeight), - ); + const availW = Math.max(viewport.width - 2 * padding, 1); + const availH = Math.max(viewport.height - 2 * padding, 1); + const zoom = Math.log2(Math.min(availW / roiWidth, availH / roiHeight)); setViewState({ zoom, target: [(b.x1 + b.x2) / 2, (b.y1 + b.y2) / 2], diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index f12d8c75..aa0cd949 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -116,6 +116,11 @@ export function useRoiFields(): UseRoiFieldsReturn { const newCorner1: [number, number] = [Math.min(px1, px2), Math.min(py1, py2)]; const newCorner2: [number, number] = [Math.max(px1, px2), Math.max(py1, py2)]; + const parsedZ1 = nz1 !== "" ? Number(nz1) : undefined; + const parsedZ2 = nz2 !== "" ? Number(nz2) : undefined; + if ((parsedZ1 !== undefined && Number.isNaN(parsedZ1)) || (parsedZ2 !== undefined && Number.isNaN(parsedZ2))) + return; + if (editingRoiId) { setSavedRois((prev) => prev.map((r) => { @@ -124,8 +129,8 @@ export function useRoiFields(): UseRoiFieldsReturn { ...r, corner1: newCorner1, corner2: newCorner2, - z1: nz1 !== "" ? Number(nz1) : r.z1, - z2: nz2 !== "" ? Number(nz2) : r.z2, + z1: parsedZ1 ?? r.z1, + z2: parsedZ2 ?? r.z2, }; }), ); @@ -138,8 +143,8 @@ export function useRoiFields(): UseRoiFieldsReturn { return { corner1: newCorner1, corner2: newCorner2, - z1: nz1 !== "" ? Number(nz1) : prev.z1, - z2: nz2 !== "" ? Number(nz2) : prev.z2, + z1: parsedZ1 ?? prev.z1, + z2: parsedZ2 ?? prev.z2, }; }); }, @@ -190,6 +195,7 @@ export function useRoiFields(): UseRoiFieldsReturn { if ([nx1, ny1, nx2, ny2].some(Number.isNaN)) return; const nz1 = z1 !== "" ? Number(z1) : pendingRoi.z1; const nz2 = z2 !== "" ? Number(z2) : pendingRoi.z2; + if (Number.isNaN(nz1) || Number.isNaN(nz2)) return; const raw = { corner1: [nx1, ny1] as [number, number], corner2: [nx2, ny2] as [number, number], diff --git a/sites/app/src/App.tsx b/sites/app/src/App.tsx index f9cfedad..b19209a8 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -48,27 +48,26 @@ export default function App() { } }, [roiReady]); - const { sources, viewState, enableRoi } = React.useMemo(() => { - const url = new URL(urlString); - const { searchParams } = url; - - // Don't touch the URL until we know whether the plugin is available. - if (roiReady) { - if (roiAvailable) { - // Plugin installed — ensure `roi` param is visible (default: "0") - if (!searchParams.has("roi")) { - searchParams.set("roi", "0"); - window.history.replaceState(window.history.state, "", decodeURIComponent(url.href)); - } - } else { - // Plugin not installed — remove stale roi param - if (searchParams.has("roi")) { - searchParams.delete("roi"); - window.history.replaceState(window.history.state, "", decodeURIComponent(url.href)); - } + // Sync the `roi` URL param once we know whether the plugin is available. + React.useEffect(() => { + if (!roiReady) return; + const url = new URL(window.location.href); + if (roiAvailable) { + if (!url.searchParams.has("roi")) { + url.searchParams.set("roi", "0"); + window.history.replaceState(window.history.state, "", url.href); + } + } else { + if (url.searchParams.has("roi")) { + url.searchParams.delete("roi"); + window.history.replaceState(window.history.state, "", url.href); } } + }, [roiReady]); + const { sources, viewState, enableRoi } = React.useMemo(() => { + const url = new URL(urlString); + const { searchParams } = url; return { sources: searchParams.getAll("source"), viewState: parseViewStateFromUrl(), @@ -88,7 +87,7 @@ export default function App() { zoom: update.zoom, }), ); - window.history.replaceState(window.history.state, "", decodeURIComponent(url.href)); + window.history.replaceState(window.history.state, "", url.href); }, 200), [], ); diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 225b295c..12eadf28 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -139,7 +139,7 @@ export interface OverlayPolygon { export interface DeckExtension { /** Polygon overlay specifications to render on the canvas. */ overlays?: OverlayPolygon[]; - /** Click handler. Receives image-space coordinates. Return true to consume the event. */ + /** Click handler. Receives image-space coordinates. Return true to stop propagation to other registered extensions (does not prevent layer-level click handlers from running). */ onClick?: (coordinate: [number, number]) => boolean; /** Hover handler. Receives image-space coordinates or null when leaving the canvas. */ onHover?: (coordinate: [number, number] | null) => void; From 5d5abf22f7886a01a91ba074424d3dd24cafdbe8 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 18 Mar 2026 16:47:31 +0100 Subject: [PATCH 10/32] refactor: replace runtime fallback with compile-time toggle --- sites/app/src/App.tsx | 76 ++++++++++++++------------------- sites/app/src/roi-selector.d.ts | 9 +++- sites/app/vite.config.js | 11 +++-- 3 files changed, 49 insertions(+), 47 deletions(-) diff --git a/sites/app/src/App.tsx b/sites/app/src/App.tsx index b19209a8..e251f22d 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -3,21 +3,27 @@ import debounce from "just-debounce-it"; import * as React from "react"; /** - * Lazy load the optional ROI selector plugin. - * If `@biongff/roi-selector` is not installed (or substituted by the - * optionalDeps Vite plugin), this resolves to `null` and nothing renders. + * `__ROI_AVAILABLE__` is a compile-time constant injected by Vite based on + * whether `roi-selector` is active in pnpm-workspace.yaml. + * When disabled, the import is dead-code-eliminated in production builds; + * in dev mode the `optionalDeps` Vite plugin stubs the module. */ -const roiPromise: Promise<{ default: React.ComponentType } | null> = import("@biongff/roi-selector") - .then((mod) => (typeof mod.RoiSelector === "function" ? { default: mod.RoiSelector } : null)) - .catch(() => null); +const LazyRoiSelector = __ROI_AVAILABLE__ + ? React.lazy(() => import("@biongff/roi-selector").then((m) => ({ default: m.RoiSelector }))) + : null; -/** True once we know the plugin is available (resolved at module level). */ -let roiAvailable = false; -roiPromise.then((m) => { - roiAvailable = m !== null; -}); - -const LazyRoiSelector = React.lazy(() => roiPromise.then((m) => m ?? { default: (() => null) as unknown as React.FC })); +class RoiErrorBoundary extends React.Component { + state = { hasError: false }; + static getDerivedStateFromError() { + return { hasError: true }; + } + componentDidCatch(error: unknown) { + console.error("[ROI Selector]", error); + } + render() { + return this.state.hasError ? null : this.props.children; + } +} function parseViewStateFromUrl(): ViewState | undefined { const url = new URL(window.location.href); @@ -37,33 +43,15 @@ function parseViewStateFromUrl(): ViewState | undefined { export default function App() { const urlString = window.location.href; - // Re-render once we know whether the ROI plugin is available (async check). - const [roiReady, setRoiReady] = React.useState(roiAvailable); + // Add ?roi=0 param when ROI plugin is compiled in but param is missing. React.useEffect(() => { - if (!roiReady) { - roiPromise.then((m) => { - roiAvailable = m !== null; - setRoiReady(true); - }); - } - }, [roiReady]); - - // Sync the `roi` URL param once we know whether the plugin is available. - React.useEffect(() => { - if (!roiReady) return; + if (!__ROI_AVAILABLE__) return; const url = new URL(window.location.href); - if (roiAvailable) { - if (!url.searchParams.has("roi")) { - url.searchParams.set("roi", "0"); - window.history.replaceState(window.history.state, "", url.href); - } - } else { - if (url.searchParams.has("roi")) { - url.searchParams.delete("roi"); - window.history.replaceState(window.history.state, "", url.href); - } + if (!url.searchParams.has("roi")) { + url.searchParams.set("roi", "0"); + window.history.replaceState(window.history.state, "", url.href); } - }, [roiReady]); + }, []); const { sources, viewState, enableRoi } = React.useMemo(() => { const url = new URL(urlString); @@ -71,9 +59,9 @@ export default function App() { return { sources: searchParams.getAll("source"), viewState: parseViewStateFromUrl(), - enableRoi: roiAvailable && searchParams.get("roi") === "1", + enableRoi: __ROI_AVAILABLE__ && searchParams.get("roi") === "1", }; - }, [urlString, roiReady]); + }, [urlString]); // Debounced viewState change handler const handleViewStateChange = React.useMemo( @@ -95,10 +83,12 @@ export default function App() { return (
- {enableRoi && ( - - - + {enableRoi && LazyRoiSelector && ( + + + + + )}
diff --git a/sites/app/src/roi-selector.d.ts b/sites/app/src/roi-selector.d.ts index 53f97813..ddddb8d8 100644 --- a/sites/app/src/roi-selector.d.ts +++ b/sites/app/src/roi-selector.d.ts @@ -1,8 +1,15 @@ /** * Type declaration for the optional @biongff/roi-selector plugin. - * This ensures TypeScript doesn't error when the package is not installed. + * The optionalDeps Vite plugin substitutes an empty module when the + * package is disabled in pnpm-workspace.yaml. */ declare module "@biongff/roi-selector" { import type * as React from "react"; export const RoiSelector: React.FC; } + +/** + * Compile-time constant injected by Vite's `define` option. + * `true` when `roi-selector` is active in pnpm-workspace.yaml. + */ +declare const __ROI_AVAILABLE__: boolean; diff --git a/sites/app/vite.config.js b/sites/app/vite.config.js index 3634fc8f..df39c9e0 100644 --- a/sites/app/vite.config.js +++ b/sites/app/vite.config.js @@ -26,13 +26,18 @@ export default defineConfig(({ mode }) => ({ })); export default defineConfig(({ mode }) => { - // Read workspace config to determine which packages are active const wsPath = path.resolve(__dirname, "../../pnpm-workspace.yaml"); const wsContent = fs.readFileSync(wsPath, "utf-8"); - const roiActive = /^\s*-\s*['"]?roi-selector['"]?\s*$/m.test(wsContent); + const roiActive = isWorkspaceFolderActive(wsContent, "roi-selector"); + + const disabledPackages = new Set(); + if (!roiActive) disabledPackages.add("@biongff/roi-selector"); return { - plugins: [optionalDeps({ "@biongff/roi-selector": "roi-selector" }), react()], + plugins: [optionalDeps(disabledPackages), react()], + define: { + __ROI_AVAILABLE__: JSON.stringify(roiActive), + }, resolve: { alias: { ...(mode === "development" From daf9d000b6f0cbbc7d4b283a29e354f9db3f215d Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Fri, 27 Mar 2026 13:29:22 +0100 Subject: [PATCH 11/32] refactor(roi-selector): consolidate coordinate fields, reduce verbosity, and add conversion helpers --- roi-selector/src/RoiSelector.tsx | 28 +--- .../src/components/RoiCoordinateFields.tsx | 53 ++----- roi-selector/src/hooks/useRoiFields.ts | 147 +++++------------- roi-selector/src/state.ts | 41 +++++ 4 files changed, 99 insertions(+), 170 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index d0d561e6..80f277f9 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -28,18 +28,8 @@ function RoiSelector() { useRoiDeckExtension(); const { - x1, - y1, - x2, - y2, - z1, - z2, - onX1Change, - onY1Change, - onX2Change, - onY2Change, - onZ1Change, - onZ2Change, + coords, + onCoordChange, hasZAxis, zInfo, imageBounds, @@ -143,18 +133,8 @@ function RoiSelector() { {(pendingRoi || editingRoiId) && ( void; - onY1Change: (v: string) => void; - onX2Change: (v: string) => void; - onY2Change: (v: string) => void; - onZ1Change: (v: string) => void; - onZ2Change: (v: string) => void; + coords: CoordValues; + onCoordChange: (key: CoordKey, value: string) => void; hasZAxis: boolean; zInfo: { zMax: number } | null; imageBounds: ImageBounds | null; @@ -24,18 +15,8 @@ interface RoiCoordinateFieldsProps { const fieldSx = { color: "#fff", fontSize: 12 }; export default function RoiCoordinateFields({ - x1, - y1, - x2, - y2, - z1, - z2, - onX1Change, - onY1Change, - onX2Change, - onY2Change, - onZ1Change, - onZ2Change, + coords, + onCoordChange, hasZAxis, zInfo, imageBounds, @@ -52,8 +33,8 @@ export default function RoiCoordinateFields({ label={imageBounds ? `x₁ (0–${imageBounds.xMax})` : "x₁"} size="small" type="number" - value={x1} - onChange={(e) => onX1Change(e.target.value)} + value={coords.x1} + onChange={(e) => onCoordChange("x1", e.target.value)} fullWidth slotProps={{ input: { sx: fieldSx }, @@ -66,8 +47,8 @@ export default function RoiCoordinateFields({ label={imageBounds ? `y₁ (0–${imageBounds.yMax})` : "y₁"} size="small" type="number" - value={y1} - onChange={(e) => onY1Change(e.target.value)} + value={coords.y1} + onChange={(e) => onCoordChange("y1", e.target.value)} fullWidth slotProps={{ input: { sx: fieldSx }, @@ -87,8 +68,8 @@ export default function RoiCoordinateFields({ label={imageBounds ? `x₂ (0–${imageBounds.xMax})` : "x₂"} size="small" type="number" - value={x2} - onChange={(e) => onX2Change(e.target.value)} + value={coords.x2} + onChange={(e) => onCoordChange("x2", e.target.value)} fullWidth slotProps={{ input: { sx: fieldSx }, @@ -101,8 +82,8 @@ export default function RoiCoordinateFields({ label={imageBounds ? `y₂ (0–${imageBounds.yMax})` : "y₂"} size="small" type="number" - value={y2} - onChange={(e) => onY2Change(e.target.value)} + value={coords.y2} + onChange={(e) => onCoordChange("y2", e.target.value)} fullWidth slotProps={{ input: { sx: fieldSx }, @@ -124,8 +105,8 @@ export default function RoiCoordinateFields({ label={`z₁ (0–${zInfo.zMax})`} size="small" type="number" - value={z1} - onChange={(e) => onZ1Change(e.target.value)} + value={coords.z1} + onChange={(e) => onCoordChange("z1", e.target.value)} fullWidth slotProps={{ input: { sx: fieldSx }, @@ -138,8 +119,8 @@ export default function RoiCoordinateFields({ label={`z₂ (0–${zInfo.zMax})`} size="small" type="number" - value={z2} - onChange={(e) => onZ2Change(e.target.value)} + value={coords.z2} + onChange={(e) => onCoordChange("z2", e.target.value)} fullWidth slotProps={{ input: { sx: fieldSx }, diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index aa0cd949..2defe26b 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -8,7 +8,9 @@ import { type PendingRoi, type RoiDrawState, type SavedRoi, + boundsToCoords, clampToBounds, + coordsToRoi, nextAvailableColor, normalizeRoiBounds, pendingRoiAtom, @@ -16,21 +18,12 @@ import { savedRoisAtom, } from "../state"; +export type CoordKey = "x1" | "y1" | "x2" | "y2" | "z1" | "z2"; +export type CoordValues = Record; + export interface UseRoiFieldsReturn { - // Coordinate field string values (kept as strings so the user can type freely) - x1: string; - y1: string; - x2: string; - y2: string; - z1: string; - z2: string; - // Per-field change handlers - onX1Change: (v: string) => void; - onY1Change: (v: string) => void; - onX2Change: (v: string) => void; - onY2Change: (v: string) => void; - onZ1Change: (v: string) => void; - onZ2Change: (v: string) => void; + coords: CoordValues; + onCoordChange: (key: CoordKey, value: string) => void; // Derived / shared state hasZAxis: boolean; zInfo: { zValue: number; zMax: number } | null; @@ -60,12 +53,14 @@ export interface UseRoiFieldsReturn { * display flag that is already computed here and forwarded. */ export function useRoiFields(): UseRoiFieldsReturn { - const [x1, setX1] = useState(""); - const [y1, setY1] = useState(""); - const [x2, setX2] = useState(""); - const [y2, setY2] = useState(""); - const [z1, setZ1] = useState(""); - const [z2, setZ2] = useState(""); + const [coords, setCoords] = useState({ + x1: "", + y1: "", + x2: "", + y2: "", + z1: "", + z2: "", + }); const [editingRoiId, setEditingRoiId] = useState(null); @@ -95,43 +90,19 @@ export function useRoiFields(): UseRoiFieldsReturn { if (pendingRoi) { const bn = normalizeRoiBounds(pendingRoi); const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; - setX1(String(b.x1)); - setY1(String(b.y1)); - setX2(String(b.x2)); - setY2(String(b.y2)); - setZ1(String(b.z1)); - setZ2(String(b.z2)); + setCoords(boundsToCoords(b) as CoordValues); } }, [pendingRoi, imageBounds]); // ---- Live sync: field changes → atom (for overlay preview) ---- const syncFieldsToPending = React.useCallback( - (nx1: string, ny1: string, nx2: string, ny2: string, nz1: string, nz2: string) => { - const px1 = Number(nx1); - const py1 = Number(ny1); - const px2 = Number(nx2); - const py2 = Number(ny2); - if ([px1, py1, px2, py2].some(Number.isNaN)) return; - - const newCorner1: [number, number] = [Math.min(px1, px2), Math.min(py1, py2)]; - const newCorner2: [number, number] = [Math.max(px1, px2), Math.max(py1, py2)]; - - const parsedZ1 = nz1 !== "" ? Number(nz1) : undefined; - const parsedZ2 = nz2 !== "" ? Number(nz2) : undefined; - if ((parsedZ1 !== undefined && Number.isNaN(parsedZ1)) || (parsedZ2 !== undefined && Number.isNaN(parsedZ2))) - return; - + (next: CoordValues) => { if (editingRoiId) { setSavedRois((prev) => prev.map((r) => { if (r.id !== editingRoiId) return r; - return { - ...r, - corner1: newCorner1, - corner2: newCorner2, - z1: parsedZ1 ?? r.z1, - z2: parsedZ2 ?? r.z2, - }; + const parsed = coordsToRoi(next, r); + return parsed ? { ...r, ...parsed } : r; }), ); return; @@ -139,42 +110,25 @@ export function useRoiFields(): UseRoiFieldsReturn { setPendingRoi((prev) => { if (!prev) return prev; + const parsed = coordsToRoi(next, prev); + if (!parsed) return prev; internalUpdate.current = true; - return { - corner1: newCorner1, - corner2: newCorner2, - z1: parsedZ1 ?? prev.z1, - z2: parsedZ2 ?? prev.z2, - }; + return parsed; }); }, [editingRoiId, setSavedRois, setPendingRoi], ); - const onX1Change = (v: string) => { - setX1(v); - syncFieldsToPending(v, y1, x2, y2, z1, z2); - }; - const onY1Change = (v: string) => { - setY1(v); - syncFieldsToPending(x1, v, x2, y2, z1, z2); - }; - const onX2Change = (v: string) => { - setX2(v); - syncFieldsToPending(x1, y1, v, y2, z1, z2); - }; - const onY2Change = (v: string) => { - setY2(v); - syncFieldsToPending(x1, y1, x2, v, z1, z2); - }; - const onZ1Change = (v: string) => { - setZ1(v); - syncFieldsToPending(x1, y1, x2, y2, v, z2); - }; - const onZ2Change = (v: string) => { - setZ2(v); - syncFieldsToPending(x1, y1, x2, y2, z1, v); - }; + const onCoordChange = React.useCallback( + (key: CoordKey, value: string) => { + setCoords((prev) => { + const next = { ...prev, [key]: value }; + syncFieldsToPending(next); + return next; + }); + }, + [syncFieldsToPending], + ); // ---- Draw-mode toggle ---- const handleToggleDraw = () => { @@ -188,20 +142,8 @@ export function useRoiFields(): UseRoiFieldsReturn { // ---- Save pending ROI ---- const handleSaveRoi = () => { if (!pendingRoi) return; - const nx1 = Number(x1); - const ny1 = Number(y1); - const nx2 = Number(x2); - const ny2 = Number(y2); - if ([nx1, ny1, nx2, ny2].some(Number.isNaN)) return; - const nz1 = z1 !== "" ? Number(z1) : pendingRoi.z1; - const nz2 = z2 !== "" ? Number(z2) : pendingRoi.z2; - if (Number.isNaN(nz1) || Number.isNaN(nz2)) return; - const raw = { - corner1: [nx1, ny1] as [number, number], - corner2: [nx2, ny2] as [number, number], - z1: nz1, - z2: nz2, - }; + const raw = coordsToRoi(coords, pendingRoi); + if (!raw) return; const b = imageBounds ? clampToBounds(normalizeRoiBounds(raw), imageBounds) : normalizeRoiBounds(raw); setSavedRois((prev) => [ ...prev, @@ -236,12 +178,7 @@ export function useRoiFields(): UseRoiFieldsReturn { setEditingRoiId(roi.id); const bn = normalizeRoiBounds(roi); const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; - setX1(String(b.x1)); - setY1(String(b.y1)); - setX2(String(b.x2)); - setY2(String(b.y2)); - setZ1(String(b.z1)); - setZ2(String(b.z2)); + setCoords(boundsToCoords(b) as CoordValues); }; const handleUpdateRoi = () => { @@ -259,18 +196,8 @@ export function useRoiFields(): UseRoiFieldsReturn { }; return { - x1, - y1, - x2, - y2, - z1, - z2, - onX1Change, - onY1Change, - onX2Change, - onY2Change, - onZ1Change, - onZ2Change, + coords, + onCoordChange, hasZAxis, zInfo, imageBounds, diff --git a/roi-selector/src/state.ts b/roi-selector/src/state.ts index 5134abb6..0e90c6a4 100644 --- a/roi-selector/src/state.ts +++ b/roi-selector/src/state.ts @@ -65,6 +65,47 @@ export function normalizeRoiBounds(roi: { }; } +/** Convert NormalizedBounds to string-keyed form for text fields. */ +export function boundsToCoords(b: NormalizedBounds): Record { + return { + x1: String(b.x1), + y1: String(b.y1), + x2: String(b.x2), + y2: String(b.y2), + z1: String(b.z1), + z2: String(b.z2), + }; +} + +/** + * Parse string coordinate fields into the corner1/corner2 + z1/z2 shape + * used by SavedRoi / PendingRoi. Returns `null` when any xy value is NaN. + */ +export function coordsToRoi( + c: Record<"x1" | "y1" | "x2" | "y2" | "z1" | "z2", string>, + fallbackZ?: { z1: number; z2: number }, +): { + corner1: [number, number]; + corner2: [number, number]; + z1: number; + z2: number; +} | null { + const nx1 = Number(c.x1); + const ny1 = Number(c.y1); + const nx2 = Number(c.x2); + const ny2 = Number(c.y2); + if ([nx1, ny1, nx2, ny2].some(Number.isNaN)) return null; + const nz1 = c.z1 !== "" ? Number(c.z1) : (fallbackZ?.z1 ?? 0); + const nz2 = c.z2 !== "" ? Number(c.z2) : (fallbackZ?.z2 ?? 0); + if (Number.isNaN(nz1) || Number.isNaN(nz2)) return null; + return { + corner1: [nx1, ny1], + corner2: [nx2, ny2], + z1: nz1, + z2: nz2, + }; +} + /** Spatial dimensions of the loaded image, used for bounds clamping. */ export interface ImageBounds { xMax: number; From f4ab86a8a3c794001925a765a032219f5967c5ad Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Fri, 27 Mar 2026 18:23:21 +0100 Subject: [PATCH 12/32] refactor: revert conditional roi-selector plugin logic --- pnpm-lock.yaml | 7 +++---- sites/app/package.json | 4 +--- sites/app/src/App.tsx | 36 +++------------------------------ sites/app/src/roi-selector.d.ts | 11 ---------- sites/app/vite.config.js | 36 ++------------------------------- 5 files changed, 9 insertions(+), 85 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f147452..dd70167a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: sites/app: dependencies: + '@biongff/roi-selector': + specifier: workspace:* + version: link:../../roi-selector '@biongff/vizarr': specifier: workspace:* version: link:../../viewer @@ -137,10 +140,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - optionalDependencies: - '@biongff/roi-selector': - specifier: workspace:* - version: link:../../roi-selector devDependencies: '@types/node': specifier: ^24.3.0 diff --git a/sites/app/package.json b/sites/app/package.json index 6305ad6f..a4773de4 100644 --- a/sites/app/package.json +++ b/sites/app/package.json @@ -8,10 +8,8 @@ "preview": "vite preview", "check": "tsc" }, - "optionalDependencies": { - "@biongff/roi-selector": "workspace:*" - }, "dependencies": { + "@biongff/roi-selector": "workspace:*", "@biongff/vizarr": "workspace:*", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/sites/app/src/App.tsx b/sites/app/src/App.tsx index e251f22d..8c6f09a3 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -1,30 +1,8 @@ +import { RoiSelector } from "@biongff/roi-selector"; import { type ViewState, Vizarr } from "@biongff/vizarr"; import debounce from "just-debounce-it"; import * as React from "react"; -/** - * `__ROI_AVAILABLE__` is a compile-time constant injected by Vite based on - * whether `roi-selector` is active in pnpm-workspace.yaml. - * When disabled, the import is dead-code-eliminated in production builds; - * in dev mode the `optionalDeps` Vite plugin stubs the module. - */ -const LazyRoiSelector = __ROI_AVAILABLE__ - ? React.lazy(() => import("@biongff/roi-selector").then((m) => ({ default: m.RoiSelector }))) - : null; - -class RoiErrorBoundary extends React.Component { - state = { hasError: false }; - static getDerivedStateFromError() { - return { hasError: true }; - } - componentDidCatch(error: unknown) { - console.error("[ROI Selector]", error); - } - render() { - return this.state.hasError ? null : this.props.children; - } -} - function parseViewStateFromUrl(): ViewState | undefined { const url = new URL(window.location.href); const viewStateString = url.searchParams.get("viewState"); @@ -43,9 +21,7 @@ function parseViewStateFromUrl(): ViewState | undefined { export default function App() { const urlString = window.location.href; - // Add ?roi=0 param when ROI plugin is compiled in but param is missing. React.useEffect(() => { - if (!__ROI_AVAILABLE__) return; const url = new URL(window.location.href); if (!url.searchParams.has("roi")) { url.searchParams.set("roi", "0"); @@ -59,7 +35,7 @@ export default function App() { return { sources: searchParams.getAll("source"), viewState: parseViewStateFromUrl(), - enableRoi: __ROI_AVAILABLE__ && searchParams.get("roi") === "1", + enableRoi: searchParams.get("roi") === "1", }; }, [urlString]); @@ -83,13 +59,7 @@ export default function App() { return (
- {enableRoi && LazyRoiSelector && ( - - - - - - )} + {enableRoi && }
); diff --git a/sites/app/src/roi-selector.d.ts b/sites/app/src/roi-selector.d.ts index ddddb8d8..d0bc0f12 100644 --- a/sites/app/src/roi-selector.d.ts +++ b/sites/app/src/roi-selector.d.ts @@ -1,15 +1,4 @@ -/** - * Type declaration for the optional @biongff/roi-selector plugin. - * The optionalDeps Vite plugin substitutes an empty module when the - * package is disabled in pnpm-workspace.yaml. - */ declare module "@biongff/roi-selector" { import type * as React from "react"; export const RoiSelector: React.FC; } - -/** - * Compile-time constant injected by Vite's `define` option. - * `true` when `roi-selector` is active in pnpm-workspace.yaml. - */ -declare const __ROI_AVAILABLE__: boolean; diff --git a/sites/app/vite.config.js b/sites/app/vite.config.js index df39c9e0..47f1249c 100644 --- a/sites/app/vite.config.js +++ b/sites/app/vite.config.js @@ -1,4 +1,3 @@ -import * as fs from "node:fs"; import * as path from "node:path"; import react from "@vitejs/plugin-react"; @@ -6,46 +5,15 @@ import { defineConfig } from "vite"; const source = process.env.VIZARR_DATA || "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001253.zarr"; -export default defineConfig(({ mode }) => ({ - plugins: [react()], - base: "./", - resolve: { - alias: { - ...(mode === "development" - ? { - "@biongff/vizarr": path.resolve(__dirname, "../../viewer/src/index.tsx"), - } - : {}), - }, - load(id) { - if (id.startsWith("\0optional:")) { - return "export default {}"; - } - }, - }, -})); - export default defineConfig(({ mode }) => { - const wsPath = path.resolve(__dirname, "../../pnpm-workspace.yaml"); - const wsContent = fs.readFileSync(wsPath, "utf-8"); - const roiActive = isWorkspaceFolderActive(wsContent, "roi-selector"); - - const disabledPackages = new Set(); - if (!roiActive) disabledPackages.add("@biongff/roi-selector"); - return { - plugins: [optionalDeps(disabledPackages), react()], - define: { - __ROI_AVAILABLE__: JSON.stringify(roiActive), - }, + plugins: [react()], resolve: { alias: { ...(mode === "development" ? { "@biongff/vizarr": path.resolve(__dirname, "../../viewer/src/index.tsx"), - ...(roiActive - ? { "@biongff/roi-selector": path.resolve(__dirname, "../../roi-selector/src/index.tsx") } - : {}), + "@biongff/roi-selector": path.resolve(__dirname, "../../roi-selector/src/index.tsx"), } : {}), }, From 5122b4602f6a1463b86afb5eaf59178189dae1b3 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 8 Apr 2026 11:27:18 +0200 Subject: [PATCH 13/32] refactor: consolidated corner data structure (vs previous x1,y1, ...), added support for t axis, z and t coordinates optional based on axis being present --- roi-selector/src/RoiSelector.tsx | 42 ++-- .../src/components/RoiCoordinateFields.tsx | 43 ++++ roi-selector/src/components/SavedRoiItem.tsx | 15 +- roi-selector/src/components/SavedRoiList.tsx | 3 + roi-selector/src/hooks/useRoiFields.ts | 32 +-- roi-selector/src/index.tsx | 4 +- roi-selector/src/state.ts | 196 ++++++++++++------ roi-selector/src/useRoiDeckExtension.ts | 66 +++--- viewer/src/index.tsx | 4 +- viewer/src/state.ts | 48 ++++- 10 files changed, 317 insertions(+), 136 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 80f277f9..88e2b6fb 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -3,7 +3,7 @@ import { Box, Collapse, IconButton, Snackbar, Tooltip, Typography } from "@mui/m import { useAtomValue, useSetAtom } from "jotai"; import React, { useState } from "react"; -import { setZSliceAtom, useViewState, viewportAtom } from "@biongff/vizarr"; +import { setTSliceAtom, setZSliceAtom, useViewState, viewportAtom } from "@biongff/vizarr"; import RoiCoordinateFields from "./components/RoiCoordinateFields"; import RoiDrawControls from "./components/RoiDrawControls"; @@ -31,7 +31,9 @@ function RoiSelector() { coords, onCoordChange, hasZAxis, + hasTAxis, zInfo, + tInfo, imageBounds, isDrawing, roiDrawState, @@ -57,13 +59,14 @@ function RoiSelector() { const [, setViewState] = useViewState(); const viewport = useAtomValue(viewportAtom); const setZSlice = useSetAtom(setZSliceAtom); + const setTSlice = useSetAtom(setTSliceAtom); /** Navigate the viewer to a saved ROI (XY + Z) and make it visible. */ const handleGoToSavedRoi = (roi: SavedRoi) => { if (!viewport) return; - const b = normalizeRoiBounds(roi); - const roiWidth = b.x2 - b.x1; - const roiHeight = b.y2 - b.y1; + const bounds = normalizeRoiBounds(roi); + const roiWidth = bounds.max.x - bounds.min.x; + const roiHeight = bounds.max.y - bounds.min.y; if (roiWidth === 0 || roiHeight === 0) return; const padding = 40; const availW = Math.max(viewport.width - 2 * padding, 1); @@ -71,14 +74,20 @@ function RoiSelector() { const zoom = Math.log2(Math.min(availW / roiWidth, availH / roiHeight)); setViewState({ zoom, - target: [(b.x1 + b.x2) / 2, (b.y1 + b.y2) / 2], + target: [(bounds.min.x + bounds.max.x) / 2, (bounds.min.y + bounds.max.y) / 2], width: viewport.width, height: viewport.height, }); - if (hasZAxis && zInfo) { + if (hasZAxis && zInfo && bounds.min.z !== undefined && bounds.max.z !== undefined) { // Only jump Z if the current slice is outside the ROI's Z range. - if (zInfo.zValue < b.z1 || zInfo.zValue > b.z2) { - setZSlice(b.z1); + if (zInfo.zValue < bounds.min.z || zInfo.zValue > bounds.max.z) { + setZSlice(bounds.min.z); + } + } + if (hasTAxis && tInfo && bounds.min.t !== undefined && bounds.max.t !== undefined) { + // Only jump T if the current frame is outside the ROI's T range. + if (tInfo.tValue < bounds.min.t || tInfo.tValue > bounds.max.t) { + setTSlice(bounds.min.t); } } if (!roi.visible) { @@ -89,11 +98,15 @@ function RoiSelector() { // ---- Clipboard ---- const roiToPayload = (roi: SavedRoi): Record => { - const b = normalizeRoiBounds(roi); - const payload: Record = { x1: b.x1, y1: b.y1, x2: b.x2, y2: b.y2 }; - if (hasZAxis) { - payload.z1 = b.z1; - payload.z2 = b.z2; + const bounds = normalizeRoiBounds(roi); + const payload: Record = { x1: bounds.min.x, y1: bounds.min.y, x2: bounds.max.x, y2: bounds.max.y }; + if (hasZAxis && bounds.min.z !== undefined && bounds.max.z !== undefined) { + payload.z1 = bounds.min.z; + payload.z2 = bounds.max.z; + } + if (hasTAxis && bounds.min.t !== undefined && bounds.max.t !== undefined) { + payload.t1 = bounds.min.t; + payload.t2 = bounds.max.t; } return payload; }; @@ -136,7 +149,9 @@ function RoiSelector() { coords={coords} onCoordChange={onCoordChange} hasZAxis={hasZAxis} + hasTAxis={hasTAxis} zInfo={zInfo} + tInfo={tInfo} imageBounds={imageBounds} /> )} @@ -156,6 +171,7 @@ function RoiSelector() { setRoiMenuOpen((prev) => !prev)} diff --git a/roi-selector/src/components/RoiCoordinateFields.tsx b/roi-selector/src/components/RoiCoordinateFields.tsx index c68b3962..5f568ad9 100644 --- a/roi-selector/src/components/RoiCoordinateFields.tsx +++ b/roi-selector/src/components/RoiCoordinateFields.tsx @@ -8,7 +8,9 @@ interface RoiCoordinateFieldsProps { coords: CoordValues; onCoordChange: (key: CoordKey, value: string) => void; hasZAxis: boolean; + hasTAxis: boolean; zInfo: { zMax: number } | null; + tInfo: { tMax: number } | null; imageBounds: ImageBounds | null; } @@ -18,7 +20,9 @@ export default function RoiCoordinateFields({ coords, onCoordChange, hasZAxis, + hasTAxis, zInfo, + tInfo, imageBounds, }: RoiCoordinateFieldsProps) { return ( @@ -131,6 +135,45 @@ export default function RoiCoordinateFields({ )} + + {/* ---- T range (only when data has a T axis) ---- */} + {hasTAxis && tInfo && ( + <> + + T range (frame) + + + + onCoordChange("t1", e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: tInfo.tMax }, + }} + /> + + + onCoordChange("t2", e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: tInfo.tMax }, + }} + /> + + + + )} ); } diff --git a/roi-selector/src/components/SavedRoiItem.tsx b/roi-selector/src/components/SavedRoiItem.tsx index 4b5d7743..b781f930 100644 --- a/roi-selector/src/components/SavedRoiItem.tsx +++ b/roi-selector/src/components/SavedRoiItem.tsx @@ -7,6 +7,7 @@ import { type SavedRoi, normalizeRoiBounds } from "../state"; interface SavedRoiItemProps { roi: SavedRoi; hasZAxis: boolean; + hasTAxis: boolean; isEditing: boolean; onToggleVisibility: () => void; onGoTo: () => void; @@ -18,6 +19,7 @@ interface SavedRoiItemProps { export default function SavedRoiItem({ roi, hasZAxis, + hasTAxis, isEditing, onToggleVisibility, onGoTo, @@ -25,7 +27,7 @@ export default function SavedRoiItem({ onEdit, onDelete, }: SavedRoiItemProps) { - const b = normalizeRoiBounds(roi); + const bounds = normalizeRoiBounds(roi); return ( - ({b.x1}, {b.y1}) → ({b.x2}, {b.y2}) + ({bounds.min.x}, {bounds.min.y}) → ({bounds.max.x}, {bounds.max.y}) - {hasZAxis && ( + {hasZAxis && bounds.min.z !== undefined && bounds.max.z !== undefined && ( - z: {b.z1 === b.z2 ? b.z1 : `${b.z1}–${b.z2}`} + z: {bounds.min.z === bounds.max.z ? bounds.min.z : `${bounds.min.z}–${bounds.max.z}`} + + )} + {hasTAxis && bounds.min.t !== undefined && bounds.max.t !== undefined && ( + + t: {bounds.min.t === bounds.max.t ? bounds.min.t : `${bounds.min.t}–${bounds.max.t}`} )} diff --git a/roi-selector/src/components/SavedRoiList.tsx b/roi-selector/src/components/SavedRoiList.tsx index 0e7583dc..45ba2ddf 100644 --- a/roi-selector/src/components/SavedRoiList.tsx +++ b/roi-selector/src/components/SavedRoiList.tsx @@ -8,6 +8,7 @@ import SavedRoiItem from "./SavedRoiItem"; interface SavedRoiListProps { savedRois: SavedRoi[]; hasZAxis: boolean; + hasTAxis: boolean; editingRoiId: string | null; roiMenuOpen: boolean; onToggleOpen: () => void; @@ -22,6 +23,7 @@ interface SavedRoiListProps { export default function SavedRoiList({ savedRois, hasZAxis, + hasTAxis, editingRoiId, roiMenuOpen, onToggleOpen, @@ -69,6 +71,7 @@ export default function SavedRoiList({ key={roi.id} roi={roi} hasZAxis={hasZAxis} + hasTAxis={hasTAxis} isEditing={editingRoiId === roi.id} onToggleVisibility={() => onToggleVisibility(roi.id)} onGoTo={() => onGoTo(roi)} diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index 2defe26b..4941ff93 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -1,7 +1,7 @@ import { useAtom, useAtomValue } from "jotai"; import React, { useEffect, useRef, useState } from "react"; -import { currentImageBoundsAtom, currentZInfoAtom } from "@biongff/vizarr"; +import { currentImageBoundsAtom, currentTInfoAtom, currentZInfoAtom } from "@biongff/vizarr"; import { type ImageBounds, @@ -18,7 +18,7 @@ import { savedRoisAtom, } from "../state"; -export type CoordKey = "x1" | "y1" | "x2" | "y2" | "z1" | "z2"; +export type CoordKey = "x1" | "y1" | "x2" | "y2" | "z1" | "z2" | "t1" | "t2"; export type CoordValues = Record; export interface UseRoiFieldsReturn { @@ -26,7 +26,9 @@ export interface UseRoiFieldsReturn { onCoordChange: (key: CoordKey, value: string) => void; // Derived / shared state hasZAxis: boolean; + hasTAxis: boolean; zInfo: { zValue: number; zMax: number } | null; + tInfo: { tValue: number; tMax: number } | null; imageBounds: ImageBounds | null; isDrawing: boolean; roiDrawState: RoiDrawState; @@ -60,13 +62,17 @@ export function useRoiFields(): UseRoiFieldsReturn { y2: "", z1: "", z2: "", + t1: "", + t2: "", }); const [editingRoiId, setEditingRoiId] = useState(null); const zInfo = useAtomValue(currentZInfoAtom); + const tInfo = useAtomValue(currentTInfoAtom); const imageBounds = useAtomValue(currentImageBoundsAtom); const hasZAxis = zInfo !== null; + const hasTAxis = tInfo !== null; const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); const isDrawing = roiDrawState !== null; @@ -88,9 +94,9 @@ export function useRoiFields(): UseRoiFieldsReturn { return; } if (pendingRoi) { - const bn = normalizeRoiBounds(pendingRoi); - const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; - setCoords(boundsToCoords(b) as CoordValues); + const normalized = normalizeRoiBounds(pendingRoi); + const clamped = imageBounds ? clampToBounds(normalized, imageBounds) : normalized; + setCoords(boundsToCoords(clamped) as CoordValues); } }, [pendingRoi, imageBounds]); @@ -144,15 +150,13 @@ export function useRoiFields(): UseRoiFieldsReturn { if (!pendingRoi) return; const raw = coordsToRoi(coords, pendingRoi); if (!raw) return; - const b = imageBounds ? clampToBounds(normalizeRoiBounds(raw), imageBounds) : normalizeRoiBounds(raw); + const bounds = imageBounds ? clampToBounds(normalizeRoiBounds(raw), imageBounds) : normalizeRoiBounds(raw); setSavedRois((prev) => [ ...prev, { id: Math.random().toString(36).slice(2), - corner1: [b.x1, b.y1], - corner2: [b.x2, b.y2], - z1: b.z1, - z2: b.z2, + corner1: bounds.min, + corner2: bounds.max, color: nextAvailableColor(prev), visible: true, }, @@ -176,9 +180,9 @@ export function useRoiFields(): UseRoiFieldsReturn { if (isDrawing) setRoiDrawState(null); editOriginal.current = { ...roi }; setEditingRoiId(roi.id); - const bn = normalizeRoiBounds(roi); - const b = imageBounds ? clampToBounds(bn, imageBounds) : bn; - setCoords(boundsToCoords(b) as CoordValues); + const normalized = normalizeRoiBounds(roi); + const clamped = imageBounds ? clampToBounds(normalized, imageBounds) : normalized; + setCoords(boundsToCoords(clamped) as CoordValues); }; const handleUpdateRoi = () => { @@ -199,7 +203,9 @@ export function useRoiFields(): UseRoiFieldsReturn { coords, onCoordChange, hasZAxis, + hasTAxis, zInfo, + tInfo, imageBounds, isDrawing, roiDrawState, diff --git a/roi-selector/src/index.tsx b/roi-selector/src/index.tsx index 61f08b90..41af3b96 100644 --- a/roi-selector/src/index.tsx +++ b/roi-selector/src/index.tsx @@ -7,7 +7,9 @@ export { pendingRoiAtom, ROI_COLORS, normalizeRoiBounds, + boundsToPolygonXY, + toXY, nextAvailableColor, clampToBounds, } from "./state"; -export type { RoiDrawState, SavedRoi, PendingRoi, NormalizedBounds, ImageBounds } from "./state"; +export type { RoiCorner as RoiPoint, RoiDrawState, SavedRoi, PendingRoi, NormalizedBounds, ImageBounds } from "./state"; diff --git a/roi-selector/src/state.ts b/roi-selector/src/state.ts index 0e90c6a4..112219e7 100644 --- a/roi-selector/src/state.ts +++ b/roi-selector/src/state.ts @@ -1,33 +1,45 @@ import { atom } from "jotai"; +/** An ROI corner. x/y are always required; z and t are present only when the image has those axes. */ +export interface RoiCorner { + x: number; + y: number; + z?: number; + t?: number; +} + +/** + * Slice a RoiPoint down to an [x, y] tuple. + * Single entry point for all 2D consumers (deck.gl polygons, viewport targeting, etc.). + */ +export function toXY(p: RoiCorner): [number, number] { + return [p.x, p.y]; +} + /** * Shared state for the "draw ROI on image" feature. * * State machine: * null → draw mode is OFF * "waiting-first" → draw mode ON, waiting for the first click - * { corner1, z1 } → first corner placed, waiting for second click + * { corner1 } → first corner placed, waiting for second click */ -export type RoiDrawState = null | "waiting-first" | { corner1: [number, number]; z1: number }; +export type RoiDrawState = null | "waiting-first" | { corner1: RoiCorner }; export const roiDrawStateAtom = atom(null); /** A saved ROI with its assigned overlay color. */ export interface SavedRoi { id: string; - corner1: [number, number]; - corner2: [number, number]; - z1: number; - z2: number; + corner1: RoiCorner; + corner2: RoiCorner; color: [number, number, number]; visible: boolean; } /** A ROI that has been drawn but not yet saved or discarded. */ export interface PendingRoi { - corner1: [number, number]; - corner2: [number, number]; - z1: number; - z2: number; + corner1: RoiCorner; + corner2: RoiCorner; } export const savedRoisAtom = atom([]); @@ -35,75 +47,103 @@ export const pendingRoiAtom = atom(null); /** Normalized bounding box with guaranteed min/max ordering. */ export interface NormalizedBounds { - x1: number; - y1: number; - x2: number; - y2: number; - z1: number; - z2: number; + min: RoiCorner; + max: RoiCorner; +} + +/** + * Build a deck.gl-compatible polygon from the XY extent of normalized bounds. + * Single entry point for converting 3D bounds → 2D rectangle vertices. + */ +export function boundsToPolygonXY(bounds: NormalizedBounds): [number, number][] { + return [ + [bounds.min.x, bounds.min.y], + [bounds.max.x, bounds.min.y], + [bounds.max.x, bounds.max.y], + [bounds.min.x, bounds.max.y], + ]; } /* - * Normalize a ROI's coordinates so that (x1,y1) is the top-left and - * (x2,y2) is the bottom-right, with z1 ≤ z2. + * Normalize a ROI's corners so that min ≤ max on every axis. + * Optional axes (z, t) are only included when both corners carry them. * - * Works for both `SavedRoi` and `PendingRoi` obj. + * Works for both `SavedRoi` and `PendingRoi`. */ export function normalizeRoiBounds(roi: { - corner1: [number, number]; - corner2: [number, number]; - z1: number; - z2: number; + corner1: RoiCorner; + corner2: RoiCorner; }): NormalizedBounds { - return { - x1: Math.min(roi.corner1[0], roi.corner2[0]), - y1: Math.min(roi.corner1[1], roi.corner2[1]), - x2: Math.max(roi.corner1[0], roi.corner2[0]), - y2: Math.max(roi.corner1[1], roi.corner2[1]), - z1: Math.min(roi.z1, roi.z2), - z2: Math.max(roi.z1, roi.z2), + const min: RoiCorner = { + x: Math.min(roi.corner1.x, roi.corner2.x), + y: Math.min(roi.corner1.y, roi.corner2.y), + }; + const max: RoiCorner = { + x: Math.max(roi.corner1.x, roi.corner2.x), + y: Math.max(roi.corner1.y, roi.corner2.y), }; + if (roi.corner1.z !== undefined && roi.corner2.z !== undefined) { + min.z = Math.min(roi.corner1.z, roi.corner2.z); + max.z = Math.max(roi.corner1.z, roi.corner2.z); + } + if (roi.corner1.t !== undefined && roi.corner2.t !== undefined) { + min.t = Math.min(roi.corner1.t, roi.corner2.t); + max.t = Math.max(roi.corner1.t, roi.corner2.t); + } + return { min, max }; } /** Convert NormalizedBounds to string-keyed form for text fields. */ -export function boundsToCoords(b: NormalizedBounds): Record { - return { - x1: String(b.x1), - y1: String(b.y1), - x2: String(b.x2), - y2: String(b.y2), - z1: String(b.z1), - z2: String(b.z2), +export function boundsToCoords(bounds: NormalizedBounds): Record { + const c: Record = { + x1: String(bounds.min.x), + y1: String(bounds.min.y), + x2: String(bounds.max.x), + y2: String(bounds.max.y), }; + c.z1 = bounds.min.z !== undefined ? String(bounds.min.z) : ""; + c.z2 = bounds.max.z !== undefined ? String(bounds.max.z) : ""; + c.t1 = bounds.min.t !== undefined ? String(bounds.min.t) : ""; + c.t2 = bounds.max.t !== undefined ? String(bounds.max.t) : ""; + return c; } /** - * Parse string coordinate fields into the corner1/corner2 + z1/z2 shape - * used by SavedRoi / PendingRoi. Returns `null` when any xy value is NaN. + * Parse string coordinate fields back into the corner1/corner2 RoiPoint shape. + * Returns `null` when any XY value is NaN. + * z and t fields are included in the result only when the string is non-empty + * (or a fallback provides them). */ export function coordsToRoi( - c: Record<"x1" | "y1" | "x2" | "y2" | "z1" | "z2", string>, - fallbackZ?: { z1: number; z2: number }, -): { - corner1: [number, number]; - corner2: [number, number]; - z1: number; - z2: number; -} | null { + c: Record, + fallback?: { corner1: RoiCorner; corner2: RoiCorner }, +): { corner1: RoiCorner; corner2: RoiCorner } | null { const nx1 = Number(c.x1); const ny1 = Number(c.y1); const nx2 = Number(c.x2); const ny2 = Number(c.y2); if ([nx1, ny1, nx2, ny2].some(Number.isNaN)) return null; - const nz1 = c.z1 !== "" ? Number(c.z1) : (fallbackZ?.z1 ?? 0); - const nz2 = c.z2 !== "" ? Number(c.z2) : (fallbackZ?.z2 ?? 0); - if (Number.isNaN(nz1) || Number.isNaN(nz2)) return null; - return { - corner1: [nx1, ny1], - corner2: [nx2, ny2], - z1: nz1, - z2: nz2, - }; + + const corner1: RoiCorner = { x: nx1, y: ny1 }; + const corner2: RoiCorner = { x: nx2, y: ny2 }; + + // z — include when string is non-empty or fallback has it + const rawZ1 = c.z1 !== undefined && c.z1 !== "" ? Number(c.z1) : undefined; + const rawZ2 = c.z2 !== undefined && c.z2 !== "" ? Number(c.z2) : undefined; + const z1 = rawZ1 !== undefined ? rawZ1 : fallback?.corner1.z; + const z2 = rawZ2 !== undefined ? rawZ2 : fallback?.corner2.z; + if (z1 !== undefined && !Number.isNaN(z1)) corner1.z = z1; + if (z2 !== undefined && !Number.isNaN(z2)) corner2.z = z2; + + // t — include when string is non-empty or fallback has it + const rawT1 = c.t1 !== undefined && c.t1 !== "" ? Number(c.t1) : undefined; + const rawT2 = c.t2 !== undefined && c.t2 !== "" ? Number(c.t2) : undefined; + const t1 = rawT1 !== undefined ? rawT1 : fallback?.corner1.t; + const t2 = rawT2 !== undefined ? rawT2 : fallback?.corner2.t; + if (t1 !== undefined && !Number.isNaN(t1)) corner1.t = t1; + if (t2 !== undefined && !Number.isNaN(t2)) corner2.t = t2; + + return { corner1, corner2 }; } /** Spatial dimensions of the loaded image, used for bounds clamping. */ @@ -111,22 +151,44 @@ export interface ImageBounds { xMax: number; yMax: number; zMax: number | null; + tMax: number | null; } /** - * Clamp a normalized ROI to the image boundaries so coordinates stay within - * [0, xMax] × [0, yMax] (and [0, zMax] when a Z axis is present). + * Clamp normalized bounds to the image boundaries so coordinates stay within + * [0, xMax] × [0, yMax] (and [0, zMax] / [0, tMax] when those axes exist). */ -export function clampToBounds(b: NormalizedBounds, image: ImageBounds): NormalizedBounds { +export function clampToBounds(bounds: NormalizedBounds, image: ImageBounds): NormalizedBounds { const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); - return { - x1: clamp(b.x1, 0, image.xMax), - y1: clamp(b.y1, 0, image.yMax), - x2: clamp(b.x2, 0, image.xMax), - y2: clamp(b.y2, 0, image.yMax), - z1: image.zMax !== null ? clamp(b.z1, 0, image.zMax) : b.z1, - z2: image.zMax !== null ? clamp(b.z2, 0, image.zMax) : b.z2, + const min: RoiCorner = { + x: clamp(bounds.min.x, 0, image.xMax), + y: clamp(bounds.min.y, 0, image.yMax), }; + const max: RoiCorner = { + x: clamp(bounds.max.x, 0, image.xMax), + y: clamp(bounds.max.y, 0, image.yMax), + }; + if (bounds.min.z !== undefined && image.zMax !== null) { + min.z = clamp(bounds.min.z, 0, image.zMax); + } else if (bounds.min.z !== undefined) { + min.z = bounds.min.z; + } + if (bounds.max.z !== undefined && image.zMax !== null) { + max.z = clamp(bounds.max.z, 0, image.zMax); + } else if (bounds.max.z !== undefined) { + max.z = bounds.max.z; + } + if (bounds.min.t !== undefined && image.tMax !== null) { + min.t = clamp(bounds.min.t, 0, image.tMax); + } else if (bounds.min.t !== undefined) { + min.t = bounds.min.t; + } + if (bounds.max.t !== undefined && image.tMax !== null) { + max.t = clamp(bounds.max.t, 0, image.tMax); + } else if (bounds.max.t !== undefined) { + max.t = bounds.max.t; + } + return { min, max }; } /* Color palette (RGB) cycled through for multi-ROI overlays. */ diff --git a/roi-selector/src/useRoiDeckExtension.ts b/roi-selector/src/useRoiDeckExtension.ts index 386d5756..85c32303 100644 --- a/roi-selector/src/useRoiDeckExtension.ts +++ b/roi-selector/src/useRoiDeckExtension.ts @@ -1,9 +1,17 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { type OverlayPolygon, currentImageBoundsAtom, currentZInfoAtom, deckExtensionsAtom } from "@biongff/vizarr"; - -import { nextAvailableColor, normalizeRoiBounds, pendingRoiAtom, roiDrawStateAtom, savedRoisAtom } from "./state"; +import { type OverlayPolygon, currentImageBoundsAtom, currentTInfoAtom, currentZInfoAtom, deckExtensionsAtom } from "@biongff/vizarr"; + +import { + boundsToPolygonXY, + nextAvailableColor, + normalizeRoiBounds, + pendingRoiAtom, + roiDrawStateAtom, + savedRoisAtom, + toXY, +} from "./state"; /** * Hook that registers ROI overlay layers, click and hover handlers @@ -18,8 +26,10 @@ export function useRoiDeckExtension() { const savedRois = useAtomValue(savedRoisAtom); const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); const zInfo = useAtomValue(currentZInfoAtom); + const tInfo = useAtomValue(currentTInfoAtom); const imageBounds = useAtomValue(currentImageBoundsAtom); const currentZ = zInfo?.zValue ?? null; + const currentT = tInfo?.tValue ?? null; const nextRoiColor = nextAvailableColor(savedRois); @@ -35,23 +45,15 @@ export function useRoiDeckExtension() { const overlays = useMemo(() => { const result: OverlayPolygon[] = []; - // Saved ROIs — filtered by visibility and current Z plane + // Saved ROIs — filtered by visibility and current Z/T planes for (const roi of savedRois) { if (!roi.visible) continue; - if (currentZ !== null) { - const b = normalizeRoiBounds(roi); - if (currentZ < b.z1 || currentZ > b.z2) continue; - } - const [ax, ay] = roi.corner1; - const [bx, by] = roi.corner2; + const bounds = normalizeRoiBounds(roi); + if (currentZ !== null && bounds.min.z !== undefined && bounds.max.z !== undefined && (currentZ < bounds.min.z || currentZ > bounds.max.z)) continue; + if (currentT !== null && bounds.min.t !== undefined && bounds.max.t !== undefined && (currentT < bounds.min.t || currentT > bounds.max.t)) continue; result.push({ id: `roi-saved-${roi.id}`, - polygon: [ - [ax, ay], - [bx, ay], - [bx, by], - [ax, by], - ], + polygon: boundsToPolygonXY(bounds), fillColor: [...roi.color, 40], lineColor: [...roi.color, 200], }); @@ -59,16 +61,9 @@ export function useRoiDeckExtension() { // Pending ROI (drawn but not yet saved/discarded) if (pendingRoi) { - const [ax, ay] = pendingRoi.corner1; - const [bx, by] = pendingRoi.corner2; result.push({ id: "roi-pending", - polygon: [ - [ax, ay], - [bx, ay], - [bx, by], - [ax, by], - ], + polygon: boundsToPolygonXY(normalizeRoiBounds(pendingRoi)), fillColor: [...nextRoiColor, 60], lineColor: [...nextRoiColor, 220], }); @@ -76,7 +71,7 @@ export function useRoiDeckExtension() { // Preview rectangle (corner1 placed, following mouse) if (roiCorner1 && roiMousePos) { - const [x1, y1] = roiCorner1; + const [x1, y1] = toXY(roiCorner1); const [x2, y2] = roiMousePos; result.push({ id: "roi-preview", @@ -92,7 +87,7 @@ export function useRoiDeckExtension() { } return result; - }, [savedRois, pendingRoi, nextRoiColor, currentZ, roiCorner1, roiMousePos]); + }, [savedRois, pendingRoi, nextRoiColor, currentZ, currentT, roiCorner1, roiMousePos]); // ---- Click handler (place ROI corners, clamped to image bounds) ---- const onClick = useCallback( @@ -105,21 +100,24 @@ export function useRoiDeckExtension() { const y = imageBounds ? clampXY(rawY, imageBounds.yMax) : Math.round(rawY); const clampZ = (z: number) => imageBounds?.zMax !== null && imageBounds?.zMax !== undefined ? Math.max(0, Math.min(z, imageBounds.zMax)) : z; + const clampT = (t: number) => + imageBounds?.tMax !== null && imageBounds?.tMax !== undefined ? Math.max(0, Math.min(t, imageBounds.tMax)) : t; if (roiDrawState === "waiting-first") { - const z1 = clampZ(zInfo?.zValue ?? 0); - setRoiDrawState({ corner1: [x, y], z1 }); + const corner: import("./state").RoiCorner = { x, y }; + if (zInfo) corner.z = clampZ(zInfo.zValue); + if (tInfo) corner.t = clampT(tInfo.tValue); + setRoiDrawState({ corner1: corner }); return true; } if (roiDrawState && typeof roiDrawState === "object" && "corner1" in roiDrawState) { - const corner2: [number, number] = [x, y]; - const z2 = clampZ(zInfo?.zValue ?? 0); + const corner: import("./state").RoiCorner = { x, y }; + if (zInfo) corner.z = clampZ(zInfo.zValue); + if (tInfo) corner.t = clampT(tInfo.tValue); setPendingRoi({ corner1: roiDrawState.corner1, - corner2, - z1: roiDrawState.z1, - z2, + corner2: corner, }); setRoiDrawState(null); return true; @@ -127,7 +125,7 @@ export function useRoiDeckExtension() { return false; }, - [isDrawing, roiDrawState, setRoiDrawState, setPendingRoi, zInfo, imageBounds], + [isDrawing, roiDrawState, setRoiDrawState, setPendingRoi, zInfo, tInfo, imageBounds], ); // ---- Hover handler (track mouse for preview rectangle) ---- diff --git a/viewer/src/index.tsx b/viewer/src/index.tsx index 39ef8ed6..c18b2b96 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -14,7 +14,7 @@ export { deckExtensionsAtom, viewportAtom } from "./state"; export type { ViewportSize } from "./state"; export type { DeckExtension, OverlayPolygon } from "./state"; -// Z-axis and image-bounds utilities -export { currentZInfoAtom, setZSliceAtom, currentImageBoundsAtom } from "./state"; +// Z-axis, T-axis and image-bounds utilities +export { currentZInfoAtom, setZSliceAtom, currentTInfoAtom, setTSliceAtom, currentImageBoundsAtom } from "./state"; export { useViewState, ViewStateContext } from "./hooks"; diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 12eadf28..9ffa6c6b 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -161,12 +161,31 @@ export const currentZInfoAtom = atom((get) => { const source = sources[0]; const zAxisIndex = source.axis_labels.indexOf("z"); if (zAxisIndex === -1) return null; + const zMax = source.loader[0].shape[zAxisIndex] - 1; + if (zMax <= 0) return null; const layerState = get(layerFamilyAtom(source)); const zValue = layerState.layerProps.selections[0]?.[zAxisIndex] ?? 0; - const zMax = source.loader[0].shape[zAxisIndex] - 1; return { zValue, zMax }; }); +/** + * Derived atom that exposes the current T-axis (time) selection and metadata + * from the first loaded source. Returns null when there is no source + * or the data has no T axis. + */ +export const currentTInfoAtom = atom((get) => { + const sources = get(sourceInfoAtom); + if (sources.length === 0) return null; + const source = sources[0]; + const tAxisIndex = source.axis_labels.indexOf("t"); + if (tAxisIndex === -1) return null; + const tMax = source.loader[0].shape[tAxisIndex] - 1; + if (tMax <= 0) return null; + const layerState = get(layerFamilyAtom(source)); + const tValue = layerState.layerProps.selections[0]?.[tAxisIndex] ?? 0; + return { tValue, tMax }; +}); + /** * Derived atom that exposes the spatial X/Y (and optionally Z) size of the * first loaded source. This is the authoritative bound for ROI coordinates. @@ -182,10 +201,14 @@ export const currentImageBoundsAtom = atom((get) => { const yAxisIndex = source.axis_labels.indexOf("y"); if (xAxisIndex === -1 || yAxisIndex === -1) return null; const zAxisIndex = source.axis_labels.indexOf("z"); + const tAxisIndex = source.axis_labels.indexOf("t"); + const zSize = zAxisIndex !== -1 ? loader.shape[zAxisIndex] : 0; + const tSize = tAxisIndex !== -1 ? loader.shape[tAxisIndex] : 0; return { xMax: loader.shape[xAxisIndex] - 1, yMax: loader.shape[yAxisIndex] - 1, - zMax: zAxisIndex !== -1 ? loader.shape[zAxisIndex] - 1 : null, + zMax: zSize > 1 ? zSize - 1 : null, + tMax: tSize > 1 ? tSize - 1 : null, }; }); @@ -210,6 +233,27 @@ export const setZSliceAtom = atom(null, (get, set, zValue: number) => { } }); +/** + * Write-only atom that sets the T-axis (time) slice for all loaded sources. + * Pass a t index number and it will update every source's selection. + */ +export const setTSliceAtom = atom(null, (get, set, tValue: number) => { + const sources = get(sourceInfoAtom); + for (const source of sources) { + const tAxisIndex = source.axis_labels.indexOf("t"); + if (tAxisIndex === -1) continue; + const layerStateAtom = layerFamilyAtom(source); + set(layerStateAtom, (prev) => { + const selections = prev.layerProps.selections.map((ch) => { + const newCh = [...ch]; + newCh[tAxisIndex] = tValue; + return newCh; + }); + return { ...prev, layerProps: { ...prev.layerProps, selections } }; + }); + } +}); + export interface Redirect { url: string; message: string; From 9c17dbd910288e43f36346c276743f93b8ea75cc Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 8 Apr 2026 11:33:15 +0200 Subject: [PATCH 14/32] fix: solve bug of UI manual coord edit field able to save out of bound coords --- roi-selector/src/hooks/useRoiFields.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index 4941ff93..a7b594c5 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -128,12 +128,31 @@ export function useRoiFields(): UseRoiFieldsReturn { const onCoordChange = React.useCallback( (key: CoordKey, value: string) => { setCoords((prev) => { - const next = { ...prev, [key]: value }; + // Clamp numeric value to image bounds when available + let clamped = value; + if (value !== "" && imageBounds) { + const num = Number(value); + if (!Number.isNaN(num)) { + const limits: Partial> = { + x1: imageBounds.xMax, + x2: imageBounds.xMax, + y1: imageBounds.yMax, + y2: imageBounds.yMax, + ...(imageBounds.zMax !== null ? { z1: imageBounds.zMax, z2: imageBounds.zMax } : {}), + ...(imageBounds.tMax !== null ? { t1: imageBounds.tMax, t2: imageBounds.tMax } : {}), + }; + const max = limits[key]; + if (max !== undefined) { + clamped = String(Math.max(0, Math.min(num, max))); + } + } + } + const next = { ...prev, [key]: clamped }; syncFieldsToPending(next); return next; }); }, - [syncFieldsToPending], + [syncFieldsToPending, imageBounds], ); // ---- Draw-mode toggle ---- From e8bca7ad29fe868432fb56c9f4101712fe77f8ec Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 8 Apr 2026 13:45:21 +0200 Subject: [PATCH 15/32] feat: support roi names --- roi-selector/src/RoiSelector.tsx | 14 ++++++- .../src/components/RoiCoordinateFields.tsx | 16 ++++++++ roi-selector/src/components/SavedRoiItem.tsx | 21 +++++++++- roi-selector/src/hooks/useRoiFields.ts | 40 ++++++++++++++----- roi-selector/src/state.ts | 12 ++++++ roi-selector/src/useRoiDeckExtension.ts | 24 +++++++++-- 6 files changed, 110 insertions(+), 17 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 88e2b6fb..37b2e30b 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -30,6 +30,8 @@ function RoiSelector() { const { coords, onCoordChange, + roiName, + onRoiNameChange, hasZAxis, hasTAxis, zInfo, @@ -97,9 +99,15 @@ function RoiSelector() { }; // ---- Clipboard ---- - const roiToPayload = (roi: SavedRoi): Record => { + const roiToPayload = (roi: SavedRoi): Record => { const bounds = normalizeRoiBounds(roi); - const payload: Record = { x1: bounds.min.x, y1: bounds.min.y, x2: bounds.max.x, y2: bounds.max.y }; + const payload: Record = { + name: roi.name, + x1: bounds.min.x, + y1: bounds.min.y, + x2: bounds.max.x, + y2: bounds.max.y, + }; if (hasZAxis && bounds.min.z !== undefined && bounds.max.z !== undefined) { payload.z1 = bounds.min.z; payload.z2 = bounds.max.z; @@ -148,6 +156,8 @@ function RoiSelector() { void; + roiName: string; + onRoiNameChange: (value: string) => void; hasZAxis: boolean; hasTAxis: boolean; zInfo: { zMax: number } | null; @@ -19,6 +21,8 @@ const fieldSx = { color: "#fff", fontSize: 12 }; export default function RoiCoordinateFields({ coords, onCoordChange, + roiName, + onRoiNameChange, hasZAxis, hasTAxis, zInfo, @@ -27,6 +31,18 @@ export default function RoiCoordinateFields({ }: RoiCoordinateFieldsProps) { return ( <> + {/* ---- ROI Name ---- */} + onRoiNameChange(e.target.value)} + fullWidth + placeholder="roi_0" + slotProps={{ input: { sx: { color: "#fff", fontSize: 12 } } }} + sx={{ mb: 1 }} + /> + {/* ---- Top-left ---- */} Top-left (x₁, y₁) diff --git a/roi-selector/src/components/SavedRoiItem.tsx b/roi-selector/src/components/SavedRoiItem.tsx index b781f930..cb993da9 100644 --- a/roi-selector/src/components/SavedRoiItem.tsx +++ b/roi-selector/src/components/SavedRoiItem.tsx @@ -62,12 +62,26 @@ export default function SavedRoiItem({ - {/* Coordinates + Z info */} + {/* Name + Coordinates + Z info */} + {roi.name} + + )} {hasTAxis && bounds.min.t !== undefined && bounds.max.t !== undefined && ( - + t: {bounds.min.t === bounds.max.t ? bounds.min.t : `${bounds.min.t}–${bounds.max.t}`} )} diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index a7b594c5..d43053ca 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -12,6 +12,7 @@ import { clampToBounds, coordsToRoi, nextAvailableColor, + nextDefaultRoiName, normalizeRoiBounds, pendingRoiAtom, roiDrawStateAtom, @@ -24,6 +25,8 @@ export type CoordValues = Record; export interface UseRoiFieldsReturn { coords: CoordValues; onCoordChange: (key: CoordKey, value: string) => void; + roiName: string; + onRoiNameChange: (value: string) => void; // Derived / shared state hasZAxis: boolean; hasTAxis: boolean; @@ -67,6 +70,7 @@ export function useRoiFields(): UseRoiFieldsReturn { }); const [editingRoiId, setEditingRoiId] = useState(null); + const [roiName, setRoiName] = useState(""); const zInfo = useAtomValue(currentZInfoAtom); const tInfo = useAtomValue(currentTInfoAtom); @@ -170,17 +174,22 @@ export function useRoiFields(): UseRoiFieldsReturn { const raw = coordsToRoi(coords, pendingRoi); if (!raw) return; const bounds = imageBounds ? clampToBounds(normalizeRoiBounds(raw), imageBounds) : normalizeRoiBounds(raw); - setSavedRois((prev) => [ - ...prev, - { - id: Math.random().toString(36).slice(2), - corner1: bounds.min, - corner2: bounds.max, - color: nextAvailableColor(prev), - visible: true, - }, - ]); + setSavedRois((prev) => { + const name = roiName.trim() || nextDefaultRoiName(prev); + return [ + ...prev, + { + id: Math.random().toString(36).slice(2), + name, + corner1: bounds.min, + corner2: bounds.max, + color: nextAvailableColor(prev), + visible: true, + }, + ]; + }); setPendingRoi(null); + setRoiName(""); }; const handleDiscardRoi = () => setPendingRoi(null); @@ -199,14 +208,22 @@ export function useRoiFields(): UseRoiFieldsReturn { if (isDrawing) setRoiDrawState(null); editOriginal.current = { ...roi }; setEditingRoiId(roi.id); + setRoiName(roi.name); const normalized = normalizeRoiBounds(roi); const clamped = imageBounds ? clampToBounds(normalized, imageBounds) : normalized; setCoords(boundsToCoords(clamped) as CoordValues); }; const handleUpdateRoi = () => { + if (editingRoiId) { + const name = roiName.trim(); + if (name) { + setSavedRois((prev) => prev.map((r) => (r.id === editingRoiId ? { ...r, name } : r))); + } + } editOriginal.current = null; setEditingRoiId(null); + setRoiName(""); }; const handleCancelEdit = () => { @@ -216,11 +233,14 @@ export function useRoiFields(): UseRoiFieldsReturn { } editOriginal.current = null; setEditingRoiId(null); + setRoiName(""); }; return { coords, onCoordChange, + roiName, + onRoiNameChange: setRoiName, hasZAxis, hasTAxis, zInfo, diff --git a/roi-selector/src/state.ts b/roi-selector/src/state.ts index 112219e7..6d95b061 100644 --- a/roi-selector/src/state.ts +++ b/roi-selector/src/state.ts @@ -30,6 +30,7 @@ export const roiDrawStateAtom = atom(null); /** A saved ROI with its assigned overlay color. */ export interface SavedRoi { id: string; + name: string; corner1: RoiCorner; corner2: RoiCorner; color: [number, number, number]; @@ -211,6 +212,17 @@ export const ROI_COLORS: [number, number, number][] = [ [100, 200, 200], // cyan ]; +/** + * Generate the next default ROI name (`roi_0`, `roi_1`, …) that doesn't + * collide with any name already used by an existing ROI. + */ +export function nextDefaultRoiName(existingRois: SavedRoi[]): string { + const usedNames = new Set(existingRois.map((r) => r.name)); + let i = 0; + while (usedNames.has(`roi_${i}`)) i++; + return `roi_${i}`; +} + /* * Pick the first color from `ROI_COLORS` that isn't already used by any * existing ROI. Falls back to cycling if all colors are taken. diff --git a/roi-selector/src/useRoiDeckExtension.ts b/roi-selector/src/useRoiDeckExtension.ts index 85c32303..bad30993 100644 --- a/roi-selector/src/useRoiDeckExtension.ts +++ b/roi-selector/src/useRoiDeckExtension.ts @@ -1,7 +1,13 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { type OverlayPolygon, currentImageBoundsAtom, currentTInfoAtom, currentZInfoAtom, deckExtensionsAtom } from "@biongff/vizarr"; +import { + type OverlayPolygon, + currentImageBoundsAtom, + currentTInfoAtom, + currentZInfoAtom, + deckExtensionsAtom, +} from "@biongff/vizarr"; import { boundsToPolygonXY, @@ -49,8 +55,20 @@ export function useRoiDeckExtension() { for (const roi of savedRois) { if (!roi.visible) continue; const bounds = normalizeRoiBounds(roi); - if (currentZ !== null && bounds.min.z !== undefined && bounds.max.z !== undefined && (currentZ < bounds.min.z || currentZ > bounds.max.z)) continue; - if (currentT !== null && bounds.min.t !== undefined && bounds.max.t !== undefined && (currentT < bounds.min.t || currentT > bounds.max.t)) continue; + if ( + currentZ !== null && + bounds.min.z !== undefined && + bounds.max.z !== undefined && + (currentZ < bounds.min.z || currentZ > bounds.max.z) + ) + continue; + if ( + currentT !== null && + bounds.min.t !== undefined && + bounds.max.t !== undefined && + (currentT < bounds.min.t || currentT > bounds.max.t) + ) + continue; result.push({ id: `roi-saved-${roi.id}`, polygon: boundsToPolygonXY(bounds), From e40b2eade81e950192ddce3cb3b7a68562731a76 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 8 Apr 2026 16:50:11 +0200 Subject: [PATCH 16/32] refactor: replace shared atoms with ViewerPluginContext for plugin-viewer communication --- pnpm-lock.yaml | 3 + roi-selector/package.json | 1 + roi-selector/src/RoiSelector.tsx | 8 +- roi-selector/src/hooks/useRoiFields.ts | 8 +- roi-selector/src/useRoiDeckExtension.ts | 99 +++++++------ viewer/src/ViewerPluginContext.tsx | 40 ++++++ viewer/src/components/Viewer.tsx | 64 +++------ viewer/src/components/VizarrViewer.tsx | 177 +++++++++++++++++++++++- viewer/src/index.tsx | 9 +- viewer/src/state.ts | 29 ---- 10 files changed, 304 insertions(+), 134 deletions(-) create mode 100644 viewer/src/ViewerPluginContext.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd70167a..50da310f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: '@mui/material': specifier: ^7.2.0 version: 7.3.4(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + deck.gl: + specifier: ~9.0.0 + version: 9.0.41(@arcgis/core@4.32.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) jotai: specifier: ^1.0.0 version: 1.13.1(@babel/core@7.26.9)(@babel/template@7.26.9)(react@18.3.1) diff --git a/roi-selector/package.json b/roi-selector/package.json index 461a5a01..c1ae4e01 100644 --- a/roi-selector/package.json +++ b/roi-selector/package.json @@ -28,6 +28,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", + "deck.gl": "~9.0.0", "jotai": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 37b2e30b..b57e35ad 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -1,9 +1,8 @@ import { CropFree } from "@mui/icons-material"; import { Box, Collapse, IconButton, Snackbar, Tooltip, Typography } from "@mui/material"; -import { useAtomValue, useSetAtom } from "jotai"; import React, { useState } from "react"; -import { setTSliceAtom, setZSliceAtom, useViewState, viewportAtom } from "@biongff/vizarr"; +import { useViewerPlugin } from "@biongff/vizarr"; import RoiCoordinateFields from "./components/RoiCoordinateFields"; import RoiDrawControls from "./components/RoiDrawControls"; @@ -58,10 +57,7 @@ function RoiSelector() { const [snackOpen, setSnackOpen] = useState(false); // ---- Viewer navigation ---- - const [, setViewState] = useViewState(); - const viewport = useAtomValue(viewportAtom); - const setZSlice = useSetAtom(setZSliceAtom); - const setTSlice = useSetAtom(setTSliceAtom); + const { viewport, setViewState, setZSlice, setTSlice } = useViewerPlugin(); /** Navigate the viewer to a saved ROI (XY + Z) and make it visible. */ const handleGoToSavedRoi = (roi: SavedRoi) => { diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index d43053ca..91cd6905 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -1,7 +1,7 @@ -import { useAtom, useAtomValue } from "jotai"; +import { useAtom } from "jotai"; import React, { useEffect, useRef, useState } from "react"; -import { currentImageBoundsAtom, currentTInfoAtom, currentZInfoAtom } from "@biongff/vizarr"; +import { useViewerPlugin } from "@biongff/vizarr"; import { type ImageBounds, @@ -72,9 +72,7 @@ export function useRoiFields(): UseRoiFieldsReturn { const [editingRoiId, setEditingRoiId] = useState(null); const [roiName, setRoiName] = useState(""); - const zInfo = useAtomValue(currentZInfoAtom); - const tInfo = useAtomValue(currentTInfoAtom); - const imageBounds = useAtomValue(currentImageBoundsAtom); + const { zInfo, tInfo, imageBounds } = useViewerPlugin(); const hasZAxis = zInfo !== null; const hasTAxis = tInfo !== null; diff --git a/roi-selector/src/useRoiDeckExtension.ts b/roi-selector/src/useRoiDeckExtension.ts index bad30993..b84e06e1 100644 --- a/roi-selector/src/useRoiDeckExtension.ts +++ b/roi-selector/src/useRoiDeckExtension.ts @@ -1,13 +1,8 @@ -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { PolygonLayer } from "deck.gl"; +import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { - type OverlayPolygon, - currentImageBoundsAtom, - currentTInfoAtom, - currentZInfoAtom, - deckExtensionsAtom, -} from "@biongff/vizarr"; +import { useViewerPlugin } from "@biongff/vizarr"; import { boundsToPolygonXY, @@ -19,21 +14,23 @@ import { toXY, } from "./state"; +const PLUGIN_ID = "roi-selector"; + /** - * Hook that registers ROI overlay layers, click and hover handlers - * with the viewer's deck.gl extension system. + * Hook that builds ROI overlay layers and registers click/hover handlers + * with the viewer via ViewerPluginContext. * - * This keeps all ROI ↔ deck.gl interaction out of the core Viewer component. - * Call this once from the top-level RoiSelector component. + * The plugin owns the layer creation — the viewer just renders whatever layers + * are handed to it, with no knowledge of ROI internals. */ export function useRoiDeckExtension() { + const plugin = useViewerPlugin(); + const { imageBounds, zInfo, tInfo } = plugin; + const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); const isDrawing = roiDrawState !== null; const savedRois = useAtomValue(savedRoisAtom); const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); - const zInfo = useAtomValue(currentZInfoAtom); - const tInfo = useAtomValue(currentTInfoAtom); - const imageBounds = useAtomValue(currentImageBoundsAtom); const currentZ = zInfo?.zValue ?? null; const currentT = tInfo?.tValue ?? null; @@ -45,11 +42,15 @@ export function useRoiDeckExtension() { // Track mouse position for the preview rectangle (only while placing second corner). const [roiMousePos, setRoiMousePos] = useState<[number, number] | null>(null); - const setExtensions = useSetAtom(deckExtensionsAtom); - - // ---- Build overlay polygon specifications ---- - const overlays = useMemo(() => { - const result: OverlayPolygon[] = []; + // ---- Build real deck.gl PolygonLayer instances ---- + const roiLayers = useMemo(() => { + type PolySpec = { + id: string; + polygon: [number, number][]; + fillColor: [number, number, number, number]; + lineColor: [number, number, number, number]; + }; + const specs: PolySpec[] = []; // Saved ROIs — filtered by visibility and current Z/T planes for (const roi of savedRois) { @@ -69,7 +70,7 @@ export function useRoiDeckExtension() { (currentT < bounds.min.t || currentT > bounds.max.t) ) continue; - result.push({ + specs.push({ id: `roi-saved-${roi.id}`, polygon: boundsToPolygonXY(bounds), fillColor: [...roi.color, 40], @@ -79,7 +80,7 @@ export function useRoiDeckExtension() { // Pending ROI (drawn but not yet saved/discarded) if (pendingRoi) { - result.push({ + specs.push({ id: "roi-pending", polygon: boundsToPolygonXY(normalizeRoiBounds(pendingRoi)), fillColor: [...nextRoiColor, 60], @@ -91,7 +92,7 @@ export function useRoiDeckExtension() { if (roiCorner1 && roiMousePos) { const [x1, y1] = toXY(roiCorner1); const [x2, y2] = roiMousePos; - result.push({ + specs.push({ id: "roi-preview", polygon: [ [x1, y1], @@ -104,7 +105,21 @@ export function useRoiDeckExtension() { }); } - return result; + return specs.map( + (spec) => + new PolygonLayer({ + id: spec.id, + data: [{ polygon: spec.polygon }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: spec.fillColor, + getLineColor: spec.lineColor, + getLineWidth: 2, + lineWidthUnits: "pixels" as const, + stroked: true, + filled: true, + pickable: false, + }), + ); }, [savedRois, pendingRoi, nextRoiColor, currentZ, currentT, roiCorner1, roiMousePos]); // ---- Click handler (place ROI corners, clamped to image bounds) ---- @@ -158,26 +173,30 @@ export function useRoiDeckExtension() { [roiCorner1], ); - // ---- Register / update extension ---- + // ---- Register layers with the viewer ---- useEffect(() => { - setExtensions((prev) => ({ - ...prev, - "roi-selector": { - overlays, - onClick, - onHover, - cursor: isDrawing ? "crosshair" : undefined, - }, - })); - }, [overlays, onClick, onHover, isDrawing, setExtensions]); + plugin.addLayers(PLUGIN_ID, { + layers: roiLayers, + cursor: isDrawing ? "crosshair" : undefined, + }); + }, [roiLayers, isDrawing, plugin]); + + // ---- Register click handler ---- + useEffect(() => { + plugin.addClickHandler(PLUGIN_ID, onClick); + }, [onClick, plugin]); + + // ---- Register hover handler ---- + useEffect(() => { + plugin.addHoverHandler(PLUGIN_ID, onHover); + }, [onHover, plugin]); // ---- Cleanup on unmount ---- useEffect(() => { return () => { - setExtensions((prev) => { - const { "roi-selector": _, ...rest } = prev; - return rest; - }); + plugin.removeLayers(PLUGIN_ID); + plugin.removeClickHandler(PLUGIN_ID); + plugin.removeHoverHandler(PLUGIN_ID); }; - }, [setExtensions]); + }, [plugin]); } diff --git a/viewer/src/ViewerPluginContext.tsx b/viewer/src/ViewerPluginContext.tsx new file mode 100644 index 00000000..96277741 --- /dev/null +++ b/viewer/src/ViewerPluginContext.tsx @@ -0,0 +1,40 @@ +import type { Layer, PickingInfo } from "deck.gl"; +import * as React from "react"; +import type { ViewState, ViewportSize } from "./state"; + +export interface PluginLayerEntry { + layers: Layer[]; + cursor?: string; +} + +export interface ViewerPluginApi { + // ---- Read-only viewer data ---- + imageBounds: { xMax: number; yMax: number; zMax: number | null; tMax: number | null } | null; + zInfo: { zValue: number; zMax: number } | null; + tInfo: { tValue: number; tMax: number } | null; + viewport: ViewportSize | null; + viewState: ViewState | null; + + // ---- necessary callbacks ---- + setViewState: (vs: ViewState) => void; + setZSlice: (z: number) => void; + setTSlice: (t: number) => void; + + // ---- Plugin layer / handler registration (keyed by plugin id) ---- + addLayers: (pluginId: string, entry: PluginLayerEntry) => void; + removeLayers: (pluginId: string) => void; + addClickHandler: (pluginId: string, handler: (coordinate: [number, number]) => boolean) => void; + removeClickHandler: (pluginId: string) => void; + addHoverHandler: (pluginId: string, handler: (coordinate: [number, number] | null) => void) => void; + removeHoverHandler: (pluginId: string) => void; +} + +export const ViewerPluginContext = React.createContext(null); + +export function useViewerPlugin(): ViewerPluginApi { + const ctx = React.useContext(ViewerPluginContext); + if (!ctx) { + throw new Error("useViewerPlugin must be used within a component."); + } + return ctx; +} diff --git a/viewer/src/components/Viewer.tsx b/viewer/src/components/Viewer.tsx index 4ef89a08..e30997ed 100644 --- a/viewer/src/components/Viewer.tsx +++ b/viewer/src/components/Viewer.tsx @@ -1,18 +1,25 @@ import { ScaleBarLayer } from "@hms-dbmi/viv"; import DeckGL from "deck.gl"; -import { OrthographicView, PolygonLayer } from "deck.gl"; +import { type Layer, OrthographicView } from "deck.gl"; import { useAtom, useAtomValue } from "jotai"; import * as React from "react"; import { useViewState } from "../hooks"; import { useAxisNavigation } from "../hooks/useAxisNavigation"; -import { deckExtensionsAtom, layerAtoms, viewportAtom } from "../state"; +import { layerAtoms, viewportAtom } from "../state"; import { fitImageToViewport, getLayerSize, resolveLoaderFromLayerProps } from "../utils"; import type { DeckGLRef, OrthographicViewState, PickingInfo } from "deck.gl"; import { type GrayscaleBitmapLayerPickingInfo, LabelLayer } from "../layers/label-layer"; import type { ViewState, VizarrLayer } from "../state"; -export default function Viewer() { +interface ViewerProps { + additionalLayers?: Layer[]; + pluginCursor?: string; + onPluginClick?: (coordinate: [number, number]) => boolean; + onPluginHover?: (coordinate: [number, number] | null) => void; +} + +export default function Viewer({ additionalLayers = [], pluginCursor, onPluginClick, onPluginHover }: ViewerProps) { const deckRef = React.useRef(null); const [viewport, setViewport] = useAtom(viewportAtom); const [viewState, setViewState] = useViewState(); @@ -21,9 +28,6 @@ export default function Viewer() { const axisNavigationSnackbar = useAxisNavigation(deckRef); - // ---- Plugin extension system ---- - const extensions = useAtomValue(deckExtensionsAtom); - const resetViewState = React.useCallback( (layer: VizarrLayer) => { const { deck } = deckRef.current || {}; @@ -140,63 +144,35 @@ export default function Viewer() { }; }, [layers]); - // ---- Extension layers (polygon overlays from plugins) ---- - const extensionLayers = React.useMemo(() => { - return Object.values(extensions).flatMap((ext) => - (ext.overlays ?? []).map( - (overlay) => - new PolygonLayer({ - id: overlay.id, - data: [{ polygon: overlay.polygon }], - getPolygon: (d: { polygon: [number, number][] }) => d.polygon, - getFillColor: overlay.fillColor, - getLineColor: overlay.lineColor, - getLineWidth: overlay.lineWidth ?? 2, - lineWidthUnits: "pixels" as const, - stroked: true, - filled: true, - pickable: false, - }), - ), - ); - }, [extensions]); - - // ---- Generic click handler (delegates to registered extensions) ---- + // ---- Click handler (delegates to plugins, then falls through) ---- const handleClick = React.useCallback( (info: PickingInfo) => { if (!info.coordinate) return; const coord: [number, number] = [info.coordinate[0], info.coordinate[1]]; - for (const ext of Object.values(extensions)) { - if (ext.onClick?.(coord)) return; - } + onPluginClick?.(coord); }, - [extensions], + [onPluginClick], ); - // ---- Generic hover handler (delegates to registered extensions) ---- + // ---- Hover handler (delegates to plugins) ---- const handleHover = React.useCallback( (info: PickingInfo) => { const coord = info.coordinate ? ([info.coordinate[0], info.coordinate[1]] as [number, number]) : null; - for (const ext of Object.values(extensions)) { - ext.onHover?.(coord); - } + onPluginHover?.(coord); }, - [extensions], + [onPluginHover], ); - // ---- Generic cursor (first extension with a cursor wins, else "grab") ---- + // ---- Cursor ---- const getCursor = React.useCallback(() => { - for (const ext of Object.values(extensions)) { - if (ext.cursor) return ext.cursor; - } - return "grab"; - }, [extensions]); + return pluginCursor ?? "grab"; + }, [pluginCursor]); return ( <> diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index 22362aba..7faa5c14 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -1,19 +1,27 @@ import { Info } from "@mui/icons-material"; import { ThemeProvider } from "@mui/material"; import { Box, Link, Typography } from "@mui/material"; +import type { Layer } from "deck.gl"; import { type PrimitiveAtom, Provider, atom, useAtomValue, useSetAtom } from "jotai"; import React, { useId } from "react"; import { getSourceDataError, sourceDataValid, writeUserErrorMessage } from "../error"; -import { ViewStateContext } from "../hooks"; +import { type PluginLayerEntry, type ViewerPluginApi, ViewerPluginContext } from "../ViewerPluginContext"; +import { ViewStateContext, useViewState } from "../hooks"; import { createSourceData } from "../io"; import { type ImageLayerConfig, type ViewState, + currentImageBoundsAtom, + currentTInfoAtom, + currentZInfoAtom, redirectObjAtom, + setTSliceAtom, + setZSliceAtom, sourceErrorAtom, sourceInfoAtom, sourceWarningAtom, viewStateAtom, + viewportAtom, } from "../state"; import theme from "../theme"; import Menu from "./Menu"; @@ -27,6 +35,169 @@ export interface VizarrViewerProps { children?: React.ReactNode; } +/** + * Internal component that lives inside the jotai Provider + ViewStateContext. + * It reads viewer atoms, holds plugin layer/handler registrations in useState, + * renders + with the collected plugin layers/handlers, + * and exposes a ViewerPluginContext for children (plugins) to register themselves. + */ +function PluginBridge({ + onViewStateChange, + children, +}: { + onViewStateChange?: (viewState: ViewState) => void; + children?: React.ReactNode; +}) { + // ---- Read viewer state for the plugin context ---- + const imageBounds = useAtomValue(currentImageBoundsAtom); + const zInfo = useAtomValue(currentZInfoAtom); + const tInfo = useAtomValue(currentTInfoAtom); + const viewport = useAtomValue(viewportAtom); + const [viewState, setViewState] = useViewState(); + + const setZSlice = useSetAtom(setZSliceAtom); + const setTSlice = useSetAtom(setTSliceAtom); + + // ---- Plugin layer/handler registrations (keyed by plugin id) ---- + const [pluginLayerMap, setPluginLayerMap] = React.useState>({}); + const [clickHandlers, setClickHandlers] = React.useState boolean>>( + {}, + ); + const [hoverHandlers, setHoverHandlers] = React.useState< + Record void> + >({}); + + // ---- Flatten plugin layers for Viewer ---- + const additionalLayers: Layer[] = React.useMemo( + () => Object.values(pluginLayerMap).flatMap((entry) => entry.layers), + [pluginLayerMap], + ); + + // ---- Composite cursor: first plugin with a cursor wins ---- + const pluginCursor: string | undefined = React.useMemo(() => { + for (const entry of Object.values(pluginLayerMap)) { + if (entry.cursor) return entry.cursor; + } + return undefined; + }, [pluginLayerMap]); + + // ---- Composite click handler ---- + const handlePluginClick = React.useCallback( + (coordinate: [number, number]): boolean => { + for (const handler of Object.values(clickHandlers)) { + if (handler(coordinate)) return true; + } + return false; + }, + [clickHandlers], + ); + + // ---- Composite hover handler ---- + const handlePluginHover = React.useCallback( + (coordinate: [number, number] | null): void => { + for (const handler of Object.values(hoverHandlers)) { + handler(coordinate); + } + }, + [hoverHandlers], + ); + + // ---- Build stable plugin API ---- + const stableSetViewState = React.useCallback( + (vs: ViewState) => { + setViewState(vs); + }, + [setViewState], + ); + + const addLayers = React.useCallback( + (pluginId: string, entry: PluginLayerEntry) => setPluginLayerMap((prev) => ({ ...prev, [pluginId]: entry })), + [], + ); + const removeLayers = React.useCallback( + (pluginId: string) => + setPluginLayerMap((prev) => { + const { [pluginId]: _, ...rest } = prev; + return rest; + }), + [], + ); + const addClickHandler = React.useCallback( + (pluginId: string, handler: (coordinate: [number, number]) => boolean) => + setClickHandlers((prev) => ({ ...prev, [pluginId]: handler })), + [], + ); + const removeClickHandler = React.useCallback( + (pluginId: string) => + setClickHandlers((prev) => { + const { [pluginId]: _, ...rest } = prev; + return rest; + }), + [], + ); + const addHoverHandler = React.useCallback( + (pluginId: string, handler: (coordinate: [number, number] | null) => void) => + setHoverHandlers((prev) => ({ ...prev, [pluginId]: handler })), + [], + ); + const removeHoverHandler = React.useCallback( + (pluginId: string) => + setHoverHandlers((prev) => { + const { [pluginId]: _, ...rest } = prev; + return rest; + }), + [], + ); + + const pluginApi: ViewerPluginApi = React.useMemo( + () => ({ + imageBounds, + zInfo, + tInfo, + viewport, + viewState, + setViewState: stableSetViewState, + setZSlice, + setTSlice, + addLayers, + removeLayers, + addClickHandler, + removeClickHandler, + addHoverHandler, + removeHoverHandler, + }), + [ + imageBounds, + zInfo, + tInfo, + viewport, + viewState, + stableSetViewState, + setZSlice, + setTSlice, + addLayers, + removeLayers, + addClickHandler, + removeClickHandler, + addHoverHandler, + removeHoverHandler, + ], + ); + + return ( + + + + {children} + + ); +} + function VizarrViewerComponent({ sources = [], viewState: initialViewState, @@ -103,9 +274,7 @@ function VizarrViewerComponent({ <> {redirectObj === null && ( - - - {children} + {children} )} {sourceError !== null && ( diff --git a/viewer/src/index.tsx b/viewer/src/index.tsx index c18b2b96..fcfe1f8d 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -9,12 +9,9 @@ export type { VizarrViewer } from "./api"; export type { ViewState, ImageLayerConfig } from "./state"; -// Plugin extension system -export { deckExtensionsAtom, viewportAtom } from "./state"; +// Plugin context API +export { ViewerPluginContext, useViewerPlugin } from "./ViewerPluginContext"; +export type { ViewerPluginApi, PluginLayerEntry } from "./ViewerPluginContext"; export type { ViewportSize } from "./state"; -export type { DeckExtension, OverlayPolygon } from "./state"; - -// Z-axis, T-axis and image-bounds utilities -export { currentZInfoAtom, setZSliceAtom, currentTInfoAtom, setTSliceAtom, currentImageBoundsAtom } from "./state"; export { useViewState, ViewStateContext } from "./hooks"; diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 9ffa6c6b..b4331bdd 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -121,35 +121,6 @@ export const viewStateAtom = atom(null); export const sourceErrorAtom = atom(null); export const sourceWarningAtom = atom([]); -// ---- Plugin extension system ---- - -/** A polygon overlay specification (plain data, converted to deck.gl layers by the Viewer). */ -export interface OverlayPolygon { - id: string; - polygon: [number, number][]; - fillColor: [number, number, number, number]; - lineColor: [number, number, number, number]; - lineWidth?: number; -} - -/** - * Extension interface for plugins to inject behavior into the deck.gl viewer. - * Plugins register an extension via the `deckExtensionsAtom`. - */ -export interface DeckExtension { - /** Polygon overlay specifications to render on the canvas. */ - overlays?: OverlayPolygon[]; - /** Click handler. Receives image-space coordinates. Return true to stop propagation to other registered extensions (does not prevent layer-level click handlers from running). */ - onClick?: (coordinate: [number, number]) => boolean; - /** Hover handler. Receives image-space coordinates or null when leaving the canvas. */ - onHover?: (coordinate: [number, number] | null) => void; - /** Cursor to show when this extension is active. */ - cursor?: string; -} - -/** Registry of deck.gl extensions keyed by unique ID. */ -export const deckExtensionsAtom = atom>({}); - /** * Derived atom that exposes the current Z-axis selection and metadata * from the first loaded source. Returns null when there is no source From 9116754e4d8f805622c9bd54e956371c2d74a72c Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Thu, 9 Apr 2026 10:46:54 +0200 Subject: [PATCH 17/32] refactor: solve t/zMax info redundancy in API --- roi-selector/src/hooks/useRoiFields.ts | 16 ++++++++++------ roi-selector/src/state.ts | 25 ++++++++++++++----------- roi-selector/src/useRoiDeckExtension.ts | 6 ++---- viewer/src/ViewerPluginContext.tsx | 2 +- viewer/src/state.ts | 6 ------ 5 files changed, 27 insertions(+), 28 deletions(-) diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index 91cd6905..2fddb2e8 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -75,6 +75,8 @@ export function useRoiFields(): UseRoiFieldsReturn { const { zInfo, tInfo, imageBounds } = useViewerPlugin(); const hasZAxis = zInfo !== null; const hasTAxis = tInfo !== null; + const zMax = zInfo?.zMax ?? null; + const tMax = tInfo?.tMax ?? null; const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); const isDrawing = roiDrawState !== null; @@ -97,10 +99,10 @@ export function useRoiFields(): UseRoiFieldsReturn { } if (pendingRoi) { const normalized = normalizeRoiBounds(pendingRoi); - const clamped = imageBounds ? clampToBounds(normalized, imageBounds) : normalized; + const clamped = imageBounds ? clampToBounds(normalized, imageBounds, zMax, tMax) : normalized; setCoords(boundsToCoords(clamped) as CoordValues); } - }, [pendingRoi, imageBounds]); + }, [pendingRoi, imageBounds, zMax, tMax]); // ---- Live sync: field changes → atom (for overlay preview) ---- const syncFieldsToPending = React.useCallback( @@ -140,8 +142,8 @@ export function useRoiFields(): UseRoiFieldsReturn { x2: imageBounds.xMax, y1: imageBounds.yMax, y2: imageBounds.yMax, - ...(imageBounds.zMax !== null ? { z1: imageBounds.zMax, z2: imageBounds.zMax } : {}), - ...(imageBounds.tMax !== null ? { t1: imageBounds.tMax, t2: imageBounds.tMax } : {}), + ...(zMax !== null ? { z1: zMax, z2: zMax } : {}), + ...(tMax !== null ? { t1: tMax, t2: tMax } : {}), }; const max = limits[key]; if (max !== undefined) { @@ -154,7 +156,7 @@ export function useRoiFields(): UseRoiFieldsReturn { return next; }); }, - [syncFieldsToPending, imageBounds], + [syncFieldsToPending, imageBounds, zMax, tMax], ); // ---- Draw-mode toggle ---- @@ -171,7 +173,9 @@ export function useRoiFields(): UseRoiFieldsReturn { if (!pendingRoi) return; const raw = coordsToRoi(coords, pendingRoi); if (!raw) return; - const bounds = imageBounds ? clampToBounds(normalizeRoiBounds(raw), imageBounds) : normalizeRoiBounds(raw); + const bounds = imageBounds + ? clampToBounds(normalizeRoiBounds(raw), imageBounds, zMax, tMax) + : normalizeRoiBounds(raw); setSavedRois((prev) => { const name = roiName.trim() || nextDefaultRoiName(prev); return [ diff --git a/roi-selector/src/state.ts b/roi-selector/src/state.ts index 6d95b061..2bc4b0a9 100644 --- a/roi-selector/src/state.ts +++ b/roi-selector/src/state.ts @@ -151,15 +151,18 @@ export function coordsToRoi( export interface ImageBounds { xMax: number; yMax: number; - zMax: number | null; - tMax: number | null; } /** * Clamp normalized bounds to the image boundaries so coordinates stay within * [0, xMax] × [0, yMax] (and [0, zMax] / [0, tMax] when those axes exist). */ -export function clampToBounds(bounds: NormalizedBounds, image: ImageBounds): NormalizedBounds { +export function clampToBounds( + bounds: NormalizedBounds, + image: ImageBounds, + zMax?: number | null, + tMax?: number | null, +): NormalizedBounds { const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const min: RoiCorner = { x: clamp(bounds.min.x, 0, image.xMax), @@ -169,23 +172,23 @@ export function clampToBounds(bounds: NormalizedBounds, image: ImageBounds): Nor x: clamp(bounds.max.x, 0, image.xMax), y: clamp(bounds.max.y, 0, image.yMax), }; - if (bounds.min.z !== undefined && image.zMax !== null) { - min.z = clamp(bounds.min.z, 0, image.zMax); + if (bounds.min.z !== undefined && zMax != null) { + min.z = clamp(bounds.min.z, 0, zMax); } else if (bounds.min.z !== undefined) { min.z = bounds.min.z; } - if (bounds.max.z !== undefined && image.zMax !== null) { - max.z = clamp(bounds.max.z, 0, image.zMax); + if (bounds.max.z !== undefined && zMax != null) { + max.z = clamp(bounds.max.z, 0, zMax); } else if (bounds.max.z !== undefined) { max.z = bounds.max.z; } - if (bounds.min.t !== undefined && image.tMax !== null) { - min.t = clamp(bounds.min.t, 0, image.tMax); + if (bounds.min.t !== undefined && tMax != null) { + min.t = clamp(bounds.min.t, 0, tMax); } else if (bounds.min.t !== undefined) { min.t = bounds.min.t; } - if (bounds.max.t !== undefined && image.tMax !== null) { - max.t = clamp(bounds.max.t, 0, image.tMax); + if (bounds.max.t !== undefined && tMax != null) { + max.t = clamp(bounds.max.t, 0, tMax); } else if (bounds.max.t !== undefined) { max.t = bounds.max.t; } diff --git a/roi-selector/src/useRoiDeckExtension.ts b/roi-selector/src/useRoiDeckExtension.ts index b84e06e1..ebc2ebe8 100644 --- a/roi-selector/src/useRoiDeckExtension.ts +++ b/roi-selector/src/useRoiDeckExtension.ts @@ -131,10 +131,8 @@ export function useRoiDeckExtension() { const clampXY = (v: number, max: number) => Math.max(0, Math.min(Math.round(v), max)); const x = imageBounds ? clampXY(rawX, imageBounds.xMax) : Math.round(rawX); const y = imageBounds ? clampXY(rawY, imageBounds.yMax) : Math.round(rawY); - const clampZ = (z: number) => - imageBounds?.zMax !== null && imageBounds?.zMax !== undefined ? Math.max(0, Math.min(z, imageBounds.zMax)) : z; - const clampT = (t: number) => - imageBounds?.tMax !== null && imageBounds?.tMax !== undefined ? Math.max(0, Math.min(t, imageBounds.tMax)) : t; + const clampZ = (z: number) => (zInfo ? Math.max(0, Math.min(z, zInfo.zMax)) : z); + const clampT = (t: number) => (tInfo ? Math.max(0, Math.min(t, tInfo.tMax)) : t); if (roiDrawState === "waiting-first") { const corner: import("./state").RoiCorner = { x, y }; diff --git a/viewer/src/ViewerPluginContext.tsx b/viewer/src/ViewerPluginContext.tsx index 96277741..2ec4a0eb 100644 --- a/viewer/src/ViewerPluginContext.tsx +++ b/viewer/src/ViewerPluginContext.tsx @@ -9,7 +9,7 @@ export interface PluginLayerEntry { export interface ViewerPluginApi { // ---- Read-only viewer data ---- - imageBounds: { xMax: number; yMax: number; zMax: number | null; tMax: number | null } | null; + imageBounds: { xMax: number; yMax: number } | null; zInfo: { zValue: number; zMax: number } | null; tInfo: { tValue: number; tMax: number } | null; viewport: ViewportSize | null; diff --git a/viewer/src/state.ts b/viewer/src/state.ts index b4331bdd..b512cbc8 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -171,15 +171,9 @@ export const currentImageBoundsAtom = atom((get) => { const xAxisIndex = source.axis_labels.indexOf("x"); const yAxisIndex = source.axis_labels.indexOf("y"); if (xAxisIndex === -1 || yAxisIndex === -1) return null; - const zAxisIndex = source.axis_labels.indexOf("z"); - const tAxisIndex = source.axis_labels.indexOf("t"); - const zSize = zAxisIndex !== -1 ? loader.shape[zAxisIndex] : 0; - const tSize = tAxisIndex !== -1 ? loader.shape[tAxisIndex] : 0; return { xMax: loader.shape[xAxisIndex] - 1, yMax: loader.shape[yAxisIndex] - 1, - zMax: zSize > 1 ? zSize - 1 : null, - tMax: tSize > 1 ? tSize - 1 : null, }; }); From 0e769bed26ac4eafbb3b64ca91ff601528a09b31 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Thu, 9 Apr 2026 10:56:45 +0200 Subject: [PATCH 18/32] chore: remove redundant roi-selector.d.ts --- sites/app/src/roi-selector.d.ts | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 sites/app/src/roi-selector.d.ts diff --git a/sites/app/src/roi-selector.d.ts b/sites/app/src/roi-selector.d.ts deleted file mode 100644 index d0bc0f12..00000000 --- a/sites/app/src/roi-selector.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "@biongff/roi-selector" { - import type * as React from "react"; - export const RoiSelector: React.FC; -} From 9953b2b7e7029b9e02e906688ef21fcee37baaba Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Mon, 13 Apr 2026 15:30:10 +0200 Subject: [PATCH 19/32] fix: single roi exported as 1-item list to match fractal task requirement --- roi-selector/src/RoiSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index b57e35ad..81573847 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -116,7 +116,7 @@ function RoiSelector() { }; const handleCopySingleRoi = (roi: SavedRoi) => { - navigator.clipboard.writeText(JSON.stringify(roiToPayload(roi))).then(() => setSnackOpen(true)); + navigator.clipboard.writeText(JSON.stringify([roiToPayload(roi)], null, 2)).then(() => setSnackOpen(true)); }; const handleCopyAllRois = () => { From b79347642ff766c9ce09cb02a92f13f9b495fe8d Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Tue, 14 Apr 2026 14:33:09 +0200 Subject: [PATCH 20/32] feat: delete all button. confirmation dialog before delete 1 or more ROIs --- roi-selector/src/RoiSelector.tsx | 2 + roi-selector/src/components/SavedRoiItem.tsx | 220 +++++++++++-------- roi-selector/src/components/SavedRoiList.tsx | 59 ++++- roi-selector/src/hooks/useRoiFields.ts | 7 + 4 files changed, 191 insertions(+), 97 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 81573847..de8c527f 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -45,6 +45,7 @@ function RoiSelector() { handleSaveRoi, handleDiscardRoi, handleDeleteRoi, + handleDeleteAllRois, handleToggleVisibility, handleEditRoi, handleUpdateRoi, @@ -187,6 +188,7 @@ function RoiSelector() { onEdit={handleEditRoi} onDelete={handleDeleteRoi} onCopyAll={handleCopyAllRois} + onDeleteAll={handleDeleteAllRois} /> diff --git a/roi-selector/src/components/SavedRoiItem.tsx b/roi-selector/src/components/SavedRoiItem.tsx index cb993da9..6777c9bb 100644 --- a/roi-selector/src/components/SavedRoiItem.tsx +++ b/roi-selector/src/components/SavedRoiItem.tsx @@ -1,6 +1,17 @@ import { ContentCopy, Delete, Edit, MyLocation, VisibilityOff } from "@mui/icons-material"; -import { Box, IconButton, Tooltip, Typography } from "@mui/material"; -import React from "react"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + Tooltip, + Typography, +} from "@mui/material"; +import React, { useState } from "react"; import { type SavedRoi, normalizeRoiBounds } from "../state"; @@ -28,106 +39,127 @@ export default function SavedRoiItem({ onDelete, }: SavedRoiItemProps) { const bounds = normalizeRoiBounds(roi); + const [confirmOpen, setConfirmOpen] = useState(false); return ( - - {/* Color dot — click to toggle visibility */} - - - {roi.visible ? ( - - ) : ( - - )} - - + <> + setConfirmOpen(false)}> + Delete ROI + + Are you sure you want to delete "{roi.name}"? + + + + + + + + {/* Color dot — click to toggle visibility */} + + + {roi.visible ? ( + + ) : ( + + )} + + - {/* Name + Coordinates + Z info */} - - - {roi.name} - - - ({bounds.min.x}, {bounds.min.y}) → ({bounds.max.x}, {bounds.max.y}) - - {hasZAxis && bounds.min.z !== undefined && bounds.max.z !== undefined && ( - - z: {bounds.min.z === bounds.max.z ? bounds.min.z : `${bounds.min.z}–${bounds.max.z}`} + {/* Name + Coordinates + Z info */} + + + {roi.name} - )} - {hasTAxis && bounds.min.t !== undefined && bounds.max.t !== undefined && ( - t: {bounds.min.t === bounds.max.t ? bounds.min.t : `${bounds.min.t}–${bounds.max.t}`} + ({bounds.min.x}, {bounds.min.y}) → ({bounds.max.x}, {bounds.max.y}) - )} - + {hasZAxis && bounds.min.z !== undefined && bounds.max.z !== undefined && ( + + z: {bounds.min.z === bounds.max.z ? bounds.min.z : `${bounds.min.z}–${bounds.max.z}`} + + )} + {hasTAxis && bounds.min.t !== undefined && bounds.max.t !== undefined && ( + + t: {bounds.min.t === bounds.max.t ? bounds.min.t : `${bounds.min.t}–${bounds.max.t}`} + + )} + - {/* Action icons */} - - - - - - - - - - - - - - - - - - - - - + {/* Action icons */} + + + + + + + + + + + + + + + + + setConfirmOpen(true)} sx={{ color: "grey.500", p: 0.25 }}> + + + + + ); } diff --git a/roi-selector/src/components/SavedRoiList.tsx b/roi-selector/src/components/SavedRoiList.tsx index 45ba2ddf..9a535585 100644 --- a/roi-selector/src/components/SavedRoiList.tsx +++ b/roi-selector/src/components/SavedRoiList.tsx @@ -1,6 +1,17 @@ -import { ExpandMore, SelectAll } from "@mui/icons-material"; -import { Box, Button, Collapse, Divider, Typography } from "@mui/material"; -import React from "react"; +import { DeleteSweep, ExpandMore, SelectAll } from "@mui/icons-material"; +import { + Box, + Button, + Collapse, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + Typography, +} from "@mui/material"; +import React, { useState } from "react"; import type { SavedRoi } from "../state"; import SavedRoiItem from "./SavedRoiItem"; @@ -18,6 +29,7 @@ interface SavedRoiListProps { onEdit: (roi: SavedRoi) => void; onDelete: (id: string) => void; onCopyAll: () => void; + onDeleteAll: () => void; } export default function SavedRoiList({ @@ -33,7 +45,10 @@ export default function SavedRoiList({ onEdit, onDelete, onCopyAll, + onDeleteAll, }: SavedRoiListProps) { + const [confirmDeleteAllOpen, setConfirmDeleteAllOpen] = useState(false); + if (savedRois.length === 0) return null; return ( @@ -97,6 +112,44 @@ export default function SavedRoiList({ > Copy all ROIs + + + + setConfirmDeleteAllOpen(false)}> + Delete all ROIs + + + Are you sure you want to delete all {savedRois.length} ROI{savedRois.length !== 1 ? "s" : ""}? + + + + + + + diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index 2fddb2e8..739f3986 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -43,6 +43,7 @@ export interface UseRoiFieldsReturn { handleSaveRoi: () => void; handleDiscardRoi: () => void; handleDeleteRoi: (id: string) => void; + handleDeleteAllRois: () => void; handleToggleVisibility: (id: string) => void; handleEditRoi: (roi: SavedRoi) => void; handleUpdateRoi: () => void; @@ -201,6 +202,11 @@ export function useRoiFields(): UseRoiFieldsReturn { if (editingRoiId === id) setEditingRoiId(null); }; + const handleDeleteAllRois = () => { + setSavedRois([]); + setEditingRoiId(null); + }; + const handleToggleVisibility = (id: string) => { setSavedRois((prev) => prev.map((r) => (r.id === id ? { ...r, visible: !r.visible } : r))); }; @@ -257,6 +263,7 @@ export function useRoiFields(): UseRoiFieldsReturn { handleSaveRoi, handleDiscardRoi, handleDeleteRoi, + handleDeleteAllRois, handleToggleVisibility, handleEditRoi, handleUpdateRoi, From 504c7fd92d178faf85ffa7258c5fd43a9160f473 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Mon, 20 Apr 2026 11:52:46 +0200 Subject: [PATCH 21/32] fix: change rel path and dev constraint for fractal integration --- sites/app/vite.config.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/sites/app/vite.config.js b/sites/app/vite.config.js index 47f1249c..d29a5c83 100644 --- a/sites/app/vite.config.js +++ b/sites/app/vite.config.js @@ -7,15 +7,12 @@ const source = process.env.VIZARR_DATA || "https://uk1s3.embassy.ebi.ac.uk/idr/z export default defineConfig(({ mode }) => { return { + base: "./", plugins: [react()], resolve: { alias: { - ...(mode === "development" - ? { - "@biongff/vizarr": path.resolve(__dirname, "../../viewer/src/index.tsx"), - "@biongff/roi-selector": path.resolve(__dirname, "../../roi-selector/src/index.tsx"), - } - : {}), + "@biongff/vizarr": path.resolve(__dirname, "../../viewer/src/index.tsx"), + "@biongff/roi-selector": path.resolve(__dirname, "../../roi-selector/src/index.tsx"), }, }, server: { open: `?source=${source}` }, From ec87ac02cb0c2e47cff068e4e9b0feedc849698e Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 22 Apr 2026 10:47:52 +0200 Subject: [PATCH 22/32] refactor: remove square bracket around exported single ROI for better fractal integration --- roi-selector/src/RoiSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index de8c527f..6fd05dae 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -117,7 +117,7 @@ function RoiSelector() { }; const handleCopySingleRoi = (roi: SavedRoi) => { - navigator.clipboard.writeText(JSON.stringify([roiToPayload(roi)], null, 2)).then(() => setSnackOpen(true)); + navigator.clipboard.writeText(JSON.stringify(roiToPayload(roi), null, 2)).then(() => setSnackOpen(true)); }; const handleCopyAllRois = () => { From 59541ef121be53e4774c30d901be1158b9f351a3 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Thu, 23 Apr 2026 12:02:56 +0200 Subject: [PATCH 23/32] refactor!: lift viewer plugin context to app level. app as viewer/plugins orchestartor --- pnpm-lock.yaml | 6 - roi-selector/package.json | 5 +- roi-selector/src/RoiSelector.tsx | 59 +++++--- roi-selector/src/hooks/useRoiFields.ts | 51 ++++--- roi-selector/src/index.tsx | 19 ++- roi-selector/src/state.ts | 17 ++- roi-selector/src/useRoiDeckExtension.ts | 87 ++++++------ sites/app/src/App.tsx | 46 +++++- viewer/src/ViewerPluginContext.tsx | 40 ------ viewer/src/components/VizarrViewer.tsx | 178 ++++++++---------------- viewer/src/index.tsx | 9 +- 11 files changed, 234 insertions(+), 283 deletions(-) delete mode 100644 viewer/src/ViewerPluginContext.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50da310f..38274489 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,9 +67,6 @@ importers: roi-selector: dependencies: - '@biongff/vizarr': - specifier: workspace:* - version: link:../viewer '@emotion/react': specifier: ^11.14.0 version: 11.14.0(@types/react@18.3.18)(react@18.3.1) @@ -85,9 +82,6 @@ importers: deck.gl: specifier: ~9.0.0 version: 9.0.41(@arcgis/core@4.32.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - jotai: - specifier: ^1.0.0 - version: 1.13.1(@babel/core@7.26.9)(@babel/template@7.26.9)(react@18.3.1) react: specifier: ^18.2.0 version: 18.3.1 diff --git a/roi-selector/package.json b/roi-selector/package.json index c1ae4e01..6062b83c 100644 --- a/roi-selector/package.json +++ b/roi-selector/package.json @@ -20,16 +20,13 @@ "preview": "vite preview", "check": "tsc" }, - "dependencies": { - "@biongff/vizarr": "workspace:*" - }, + "dependencies": {}, "peerDependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "deck.gl": "~9.0.0", - "jotai": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 6fd05dae..cc00ac8f 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -1,15 +1,30 @@ import { CropFree } from "@mui/icons-material"; import { Box, Collapse, IconButton, Snackbar, Tooltip, Typography } from "@mui/material"; -import React, { useState } from "react"; - -import { useViewerPlugin } from "@biongff/vizarr"; +import type React from "react"; +import { useState } from "react"; import RoiCoordinateFields from "./components/RoiCoordinateFields"; import RoiDrawControls from "./components/RoiDrawControls"; import SavedRoiList from "./components/SavedRoiList"; import { useRoiFields } from "./hooks/useRoiFields"; -import { type SavedRoi, normalizeRoiBounds } from "./state"; -import { useRoiDeckExtension } from "./useRoiDeckExtension"; +import { + type ImageBounds, + type PendingRoi, + type RoiDrawState, + type SavedRoi, + type ViewerInfo, + normalizeRoiBounds, +} from "./state"; + +export interface RoiSelectorProps { + roiDrawState: RoiDrawState; + setRoiDrawState: React.Dispatch>; + savedRois: SavedRoi[]; + setSavedRois: React.Dispatch>; + pendingRoi: PendingRoi | null; + setPendingRoi: React.Dispatch>; + viewerInfo: ViewerInfo; +} /** * RoiSelector — a collapsible panel that lets you: @@ -23,8 +38,16 @@ import { useRoiDeckExtension } from "./useRoiDeckExtension"; * deck.gl interaction (overlays, clicks) is handled by `useRoiDeckExtension`. * This component is responsible only for panel layout, navigation, and clipboard. */ -function RoiSelector() { - useRoiDeckExtension(); +function RoiSelector({ + roiDrawState, + setRoiDrawState, + savedRois, + setSavedRois, + pendingRoi, + setPendingRoi, + viewerInfo, +}: RoiSelectorProps) { + const { imageBounds, zInfo, tInfo, viewport, setViewState, setZSlice, setTSlice } = viewerInfo; const { coords, @@ -33,13 +56,7 @@ function RoiSelector() { onRoiNameChange, hasZAxis, hasTAxis, - zInfo, - tInfo, - imageBounds, isDrawing, - roiDrawState, - pendingRoi, - savedRois, editingRoiId, handleToggleDraw, handleSaveRoi, @@ -50,16 +67,23 @@ function RoiSelector() { handleEditRoi, handleUpdateRoi, handleCancelEdit, - } = useRoiFields(); + } = useRoiFields({ + roiDrawState, + setRoiDrawState, + savedRois, + setSavedRois, + pendingRoi, + setPendingRoi, + imageBounds, + zInfo, + tInfo, + }); // ---- Panel toggle state ---- const [open, setOpen] = useState(false); const [roiMenuOpen, setRoiMenuOpen] = useState(false); const [snackOpen, setSnackOpen] = useState(false); - // ---- Viewer navigation ---- - const { viewport, setViewState, setZSlice, setTSlice } = useViewerPlugin(); - /** Navigate the viewer to a saved ROI (XY + Z) and make it visible. */ const handleGoToSavedRoi = (roi: SavedRoi) => { if (!viewport) return; @@ -90,7 +114,6 @@ function RoiSelector() { } } if (!roi.visible) { - // reuse the atom setter via handleToggleVisibility — ROI is currently hidden handleToggleVisibility(roi.id); } }; diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index 739f3986..e858e250 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -1,8 +1,5 @@ -import { useAtom } from "jotai"; import React, { useEffect, useRef, useState } from "react"; -import { useViewerPlugin } from "@biongff/vizarr"; - import { type ImageBounds, type PendingRoi, @@ -14,9 +11,6 @@ import { nextAvailableColor, nextDefaultRoiName, normalizeRoiBounds, - pendingRoiAtom, - roiDrawStateAtom, - savedRoisAtom, } from "../state"; export type CoordKey = "x1" | "y1" | "x2" | "y2" | "z1" | "z2" | "t1" | "t2"; @@ -27,16 +21,10 @@ export interface UseRoiFieldsReturn { onCoordChange: (key: CoordKey, value: string) => void; roiName: string; onRoiNameChange: (value: string) => void; - // Derived / shared state + // Derived state hasZAxis: boolean; hasTAxis: boolean; - zInfo: { zValue: number; zMax: number } | null; - tInfo: { tValue: number; tMax: number } | null; - imageBounds: ImageBounds | null; isDrawing: boolean; - roiDrawState: RoiDrawState; - pendingRoi: PendingRoi | null; - savedRois: SavedRoi[]; editingRoiId: string | null; // Handlers handleToggleDraw: () => void; @@ -50,6 +38,18 @@ export interface UseRoiFieldsReturn { handleCancelEdit: () => void; } +export interface UseRoiFieldsProps { + roiDrawState: RoiDrawState; + setRoiDrawState: React.Dispatch>; + savedRois: SavedRoi[]; + setSavedRois: React.Dispatch>; + pendingRoi: PendingRoi | null; + setPendingRoi: React.Dispatch>; + imageBounds: ImageBounds | null; + zInfo: { zValue: number; zMax: number } | null; + tInfo: { tValue: number; tMax: number } | null; +} + /** * Manages all ROI coordinate field state, draw-mode state, and the full * save / edit / delete / discard lifecycle. @@ -58,7 +58,17 @@ export interface UseRoiFieldsReturn { * parent component because they depend on viewer state and the `hasZAxis` * display flag that is already computed here and forwarded. */ -export function useRoiFields(): UseRoiFieldsReturn { +export function useRoiFields({ + roiDrawState, + setRoiDrawState, + savedRois, + setSavedRois, + pendingRoi, + setPendingRoi, + imageBounds, + zInfo, + tInfo, +}: UseRoiFieldsProps): UseRoiFieldsReturn { const [coords, setCoords] = useState({ x1: "", y1: "", @@ -73,18 +83,13 @@ export function useRoiFields(): UseRoiFieldsReturn { const [editingRoiId, setEditingRoiId] = useState(null); const [roiName, setRoiName] = useState(""); - const { zInfo, tInfo, imageBounds } = useViewerPlugin(); const hasZAxis = zInfo !== null; const hasTAxis = tInfo !== null; const zMax = zInfo?.zMax ?? null; const tMax = tInfo?.tMax ?? null; - const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); const isDrawing = roiDrawState !== null; - const [savedRois, setSavedRois] = useAtom(savedRoisAtom); - const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); - // Prevents the pendingRoi → fields effect from re-running when we are the ones // writing to pendingRoi (e.g. while the user types in the fields). const internalUpdate = useRef(false); @@ -105,7 +110,7 @@ export function useRoiFields(): UseRoiFieldsReturn { } }, [pendingRoi, imageBounds, zMax, tMax]); - // ---- Live sync: field changes → atom (for overlay preview) ---- + // ---- Live sync: field changes → state (for overlay preview) ---- const syncFieldsToPending = React.useCallback( (next: CoordValues) => { if (editingRoiId) { @@ -251,13 +256,7 @@ export function useRoiFields(): UseRoiFieldsReturn { onRoiNameChange: setRoiName, hasZAxis, hasTAxis, - zInfo, - tInfo, - imageBounds, isDrawing, - roiDrawState, - pendingRoi, - savedRois, editingRoiId, handleToggleDraw, handleSaveRoi, diff --git a/roi-selector/src/index.tsx b/roi-selector/src/index.tsx index 41af3b96..80d48403 100644 --- a/roi-selector/src/index.tsx +++ b/roi-selector/src/index.tsx @@ -1,10 +1,11 @@ export { default as RoiSelector } from "./RoiSelector"; +export type { RoiSelectorProps } from "./RoiSelector"; -// Re-export ROI state for programmatic access +export { useRoiDeckExtension } from "./useRoiDeckExtension"; +export type { UseRoiDeckExtensionProps, RoiDeckExtension } from "./useRoiDeckExtension"; + +// Re-export ROI state utilities for programmatic access export { - roiDrawStateAtom, - savedRoisAtom, - pendingRoiAtom, ROI_COLORS, normalizeRoiBounds, boundsToPolygonXY, @@ -12,4 +13,12 @@ export { nextAvailableColor, clampToBounds, } from "./state"; -export type { RoiCorner as RoiPoint, RoiDrawState, SavedRoi, PendingRoi, NormalizedBounds, ImageBounds } from "./state"; +export type { + RoiCorner as RoiPoint, + RoiDrawState, + SavedRoi, + PendingRoi, + NormalizedBounds, + ImageBounds, + ViewerInfo, +} from "./state"; diff --git a/roi-selector/src/state.ts b/roi-selector/src/state.ts index 2bc4b0a9..b681731c 100644 --- a/roi-selector/src/state.ts +++ b/roi-selector/src/state.ts @@ -1,5 +1,3 @@ -import { atom } from "jotai"; - /** An ROI corner. x/y are always required; z and t are present only when the image has those axes. */ export interface RoiCorner { x: number; @@ -25,7 +23,6 @@ export function toXY(p: RoiCorner): [number, number] { * { corner1 } → first corner placed, waiting for second click */ export type RoiDrawState = null | "waiting-first" | { corner1: RoiCorner }; -export const roiDrawStateAtom = atom(null); /** A saved ROI with its assigned overlay color. */ export interface SavedRoi { @@ -43,9 +40,6 @@ export interface PendingRoi { corner2: RoiCorner; } -export const savedRoisAtom = atom([]); -export const pendingRoiAtom = atom(null); - /** Normalized bounding box with guaranteed min/max ordering. */ export interface NormalizedBounds { min: RoiCorner; @@ -238,3 +232,14 @@ export function nextAvailableColor(existingRois: SavedRoi[]): [number, number, n // All colors in use — cycle based on count return ROI_COLORS[existingRois.length % ROI_COLORS.length]; } + +/** Viewer information passed from the host application. */ +export interface ViewerInfo { + imageBounds: ImageBounds | null; + zInfo: { zValue: number; zMax: number } | null; + tInfo: { tValue: number; tMax: number } | null; + viewport: { width: number; height: number } | null; + setViewState: (vs: { zoom: number; target: [number, number]; width: number; height: number }) => void; + setZSlice: (z: number) => void; + setTSlice: (t: number) => void; +} diff --git a/roi-selector/src/useRoiDeckExtension.ts b/roi-selector/src/useRoiDeckExtension.ts index ebc2ebe8..0115a67d 100644 --- a/roi-selector/src/useRoiDeckExtension.ts +++ b/roi-selector/src/useRoiDeckExtension.ts @@ -1,36 +1,53 @@ +import type { Layer } from "deck.gl"; import { PolygonLayer } from "deck.gl"; -import { useAtom, useAtomValue } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; - -import { useViewerPlugin } from "@biongff/vizarr"; +import { useCallback, useMemo, useState } from "react"; import { + type ImageBounds, + type PendingRoi, + type RoiDrawState, + type SavedRoi, boundsToPolygonXY, nextAvailableColor, normalizeRoiBounds, - pendingRoiAtom, - roiDrawStateAtom, - savedRoisAtom, toXY, } from "./state"; -const PLUGIN_ID = "roi-selector"; +export interface UseRoiDeckExtensionProps { + roiDrawState: RoiDrawState; + setRoiDrawState: React.Dispatch>; + savedRois: SavedRoi[]; + pendingRoi: PendingRoi | null; + setPendingRoi: React.Dispatch>; + imageBounds: ImageBounds | null; + zInfo: { zValue: number; zMax: number } | null; + tInfo: { tValue: number; tMax: number } | null; +} + +export interface RoiDeckExtension { + layers: Layer[]; + cursor: string | undefined; + onClick: (coordinate: [number, number]) => boolean; + onHover: (coordinate: [number, number] | null) => void; +} /** - * Hook that builds ROI overlay layers and registers click/hover handlers - * with the viewer via ViewerPluginContext. + * Hook that builds ROI overlay layers and click/hover handlers. * - * The plugin owns the layer creation — the viewer just renders whatever layers - * are handed to it, with no knowledge of ROI internals. + * Returns layers and handlers for the caller (App) to pass to the viewer. + * The hook has no viewer dependency — all viewer data arrives via props. */ -export function useRoiDeckExtension() { - const plugin = useViewerPlugin(); - const { imageBounds, zInfo, tInfo } = plugin; - - const [roiDrawState, setRoiDrawState] = useAtom(roiDrawStateAtom); +export function useRoiDeckExtension({ + roiDrawState, + setRoiDrawState, + savedRois, + pendingRoi, + setPendingRoi, + imageBounds, + zInfo, + tInfo, +}: UseRoiDeckExtensionProps): RoiDeckExtension { const isDrawing = roiDrawState !== null; - const savedRois = useAtomValue(savedRoisAtom); - const [pendingRoi, setPendingRoi] = useAtom(pendingRoiAtom); const currentZ = zInfo?.zValue ?? null; const currentT = tInfo?.tValue ?? null; @@ -171,30 +188,10 @@ export function useRoiDeckExtension() { [roiCorner1], ); - // ---- Register layers with the viewer ---- - useEffect(() => { - plugin.addLayers(PLUGIN_ID, { - layers: roiLayers, - cursor: isDrawing ? "crosshair" : undefined, - }); - }, [roiLayers, isDrawing, plugin]); - - // ---- Register click handler ---- - useEffect(() => { - plugin.addClickHandler(PLUGIN_ID, onClick); - }, [onClick, plugin]); - - // ---- Register hover handler ---- - useEffect(() => { - plugin.addHoverHandler(PLUGIN_ID, onHover); - }, [onHover, plugin]); - - // ---- Cleanup on unmount ---- - useEffect(() => { - return () => { - plugin.removeLayers(PLUGIN_ID); - plugin.removeClickHandler(PLUGIN_ID); - plugin.removeHoverHandler(PLUGIN_ID); - }; - }, [plugin]); + return { + layers: roiLayers, + cursor: isDrawing ? ("crosshair" as const) : undefined, + onClick, + onHover, + }; } diff --git a/sites/app/src/App.tsx b/sites/app/src/App.tsx index 8c6f09a3..e1359b50 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -1,4 +1,5 @@ -import { RoiSelector } from "@biongff/roi-selector"; +import { RoiSelector, useRoiDeckExtension } from "@biongff/roi-selector"; +import type { PendingRoi, RoiDrawState, SavedRoi, ViewerInfo } from "@biongff/roi-selector"; import { type ViewState, Vizarr } from "@biongff/vizarr"; import debounce from "just-debounce-it"; import * as React from "react"; @@ -56,10 +57,49 @@ export default function App() { [], ); + // ---- Viewer state (received from Vizarr via callback) ---- + const [viewerInfo, setViewerInfo] = React.useState(null); + + // ---- ROI state (lifted to app level) ---- + const [roiDrawState, setRoiDrawState] = React.useState(null); + const [savedRois, setSavedRois] = React.useState([]); + const [pendingRoi, setPendingRoi] = React.useState(null); + + // ---- ROI deck.gl integration (layers, click, hover) ---- + const { layers, cursor, onClick, onHover } = useRoiDeckExtension({ + roiDrawState, + setRoiDrawState, + savedRois, + pendingRoi, + setPendingRoi, + imageBounds: viewerInfo?.imageBounds ?? null, + zInfo: viewerInfo?.zInfo ?? null, + tInfo: viewerInfo?.tInfo ?? null, + }); + return (
- - {enableRoi && } + + {enableRoi && viewerInfo && ( + + )}
); diff --git a/viewer/src/ViewerPluginContext.tsx b/viewer/src/ViewerPluginContext.tsx deleted file mode 100644 index 2ec4a0eb..00000000 --- a/viewer/src/ViewerPluginContext.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { Layer, PickingInfo } from "deck.gl"; -import * as React from "react"; -import type { ViewState, ViewportSize } from "./state"; - -export interface PluginLayerEntry { - layers: Layer[]; - cursor?: string; -} - -export interface ViewerPluginApi { - // ---- Read-only viewer data ---- - imageBounds: { xMax: number; yMax: number } | null; - zInfo: { zValue: number; zMax: number } | null; - tInfo: { tValue: number; tMax: number } | null; - viewport: ViewportSize | null; - viewState: ViewState | null; - - // ---- necessary callbacks ---- - setViewState: (vs: ViewState) => void; - setZSlice: (z: number) => void; - setTSlice: (t: number) => void; - - // ---- Plugin layer / handler registration (keyed by plugin id) ---- - addLayers: (pluginId: string, entry: PluginLayerEntry) => void; - removeLayers: (pluginId: string) => void; - addClickHandler: (pluginId: string, handler: (coordinate: [number, number]) => boolean) => void; - removeClickHandler: (pluginId: string) => void; - addHoverHandler: (pluginId: string, handler: (coordinate: [number, number] | null) => void) => void; - removeHoverHandler: (pluginId: string) => void; -} - -export const ViewerPluginContext = React.createContext(null); - -export function useViewerPlugin(): ViewerPluginApi { - const ctx = React.useContext(ViewerPluginContext); - if (!ctx) { - throw new Error("useViewerPlugin must be used within a component."); - } - return ctx; -} diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index 7faa5c14..0a78bd55 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -5,12 +5,12 @@ import type { Layer } from "deck.gl"; import { type PrimitiveAtom, Provider, atom, useAtomValue, useSetAtom } from "jotai"; import React, { useId } from "react"; import { getSourceDataError, sourceDataValid, writeUserErrorMessage } from "../error"; -import { type PluginLayerEntry, type ViewerPluginApi, ViewerPluginContext } from "../ViewerPluginContext"; import { ViewStateContext, useViewState } from "../hooks"; import { createSourceData } from "../io"; import { type ImageLayerConfig, type ViewState, + type ViewportSize, currentImageBoundsAtom, currentTInfoAtom, currentZInfoAtom, @@ -28,81 +28,60 @@ import Menu from "./Menu"; import { InfoSnackbar } from "./Snackbar"; import Viewer from "./Viewer"; +/** Viewer state snapshot exposed to the host application via onViewerStateChange. */ +export interface ViewerInfo { + imageBounds: { xMax: number; yMax: number } | null; + zInfo: { zValue: number; zMax: number } | null; + tInfo: { tValue: number; tMax: number } | null; + viewport: ViewportSize | null; + setViewState: (vs: ViewState) => void; + setZSlice: (z: number) => void; + setTSlice: (t: number) => void; +} + export interface VizarrViewerProps { sources?: string[]; viewState?: ViewState; onViewStateChange?: (viewState: ViewState) => void; + onViewerStateChange?: (info: ViewerInfo) => void; + additionalLayers?: Layer[]; + pluginCursor?: string; + onPluginClick?: (coordinate: [number, number]) => boolean; + onPluginHover?: (coordinate: [number, number] | null) => void; children?: React.ReactNode; } /** * Internal component that lives inside the jotai Provider + ViewStateContext. - * It reads viewer atoms, holds plugin layer/handler registrations in useState, - * renders + with the collected plugin layers/handlers, - * and exposes a ViewerPluginContext for children (plugins) to register themselves. + * It reads viewer atoms, notifies the host of viewer state changes, + * and renders + + children. */ -function PluginBridge({ +function ViewerBridge({ onViewStateChange, + onViewerStateChange, + additionalLayers = [], + pluginCursor, + onPluginClick, + onPluginHover, children, }: { onViewStateChange?: (viewState: ViewState) => void; + onViewerStateChange?: (info: ViewerInfo) => void; + additionalLayers?: Layer[]; + pluginCursor?: string; + onPluginClick?: (coordinate: [number, number]) => boolean; + onPluginHover?: (coordinate: [number, number] | null) => void; children?: React.ReactNode; }) { - // ---- Read viewer state for the plugin context ---- const imageBounds = useAtomValue(currentImageBoundsAtom); const zInfo = useAtomValue(currentZInfoAtom); const tInfo = useAtomValue(currentTInfoAtom); const viewport = useAtomValue(viewportAtom); - const [viewState, setViewState] = useViewState(); + const [, setViewState] = useViewState(); const setZSlice = useSetAtom(setZSliceAtom); const setTSlice = useSetAtom(setTSliceAtom); - // ---- Plugin layer/handler registrations (keyed by plugin id) ---- - const [pluginLayerMap, setPluginLayerMap] = React.useState>({}); - const [clickHandlers, setClickHandlers] = React.useState boolean>>( - {}, - ); - const [hoverHandlers, setHoverHandlers] = React.useState< - Record void> - >({}); - - // ---- Flatten plugin layers for Viewer ---- - const additionalLayers: Layer[] = React.useMemo( - () => Object.values(pluginLayerMap).flatMap((entry) => entry.layers), - [pluginLayerMap], - ); - - // ---- Composite cursor: first plugin with a cursor wins ---- - const pluginCursor: string | undefined = React.useMemo(() => { - for (const entry of Object.values(pluginLayerMap)) { - if (entry.cursor) return entry.cursor; - } - return undefined; - }, [pluginLayerMap]); - - // ---- Composite click handler ---- - const handlePluginClick = React.useCallback( - (coordinate: [number, number]): boolean => { - for (const handler of Object.values(clickHandlers)) { - if (handler(coordinate)) return true; - } - return false; - }, - [clickHandlers], - ); - - // ---- Composite hover handler ---- - const handlePluginHover = React.useCallback( - (coordinate: [number, number] | null): void => { - for (const handler of Object.values(hoverHandlers)) { - handler(coordinate); - } - }, - [hoverHandlers], - ); - - // ---- Build stable plugin API ---- const stableSetViewState = React.useCallback( (vs: ViewState) => { setViewState(vs); @@ -110,91 +89,30 @@ function PluginBridge({ [setViewState], ); - const addLayers = React.useCallback( - (pluginId: string, entry: PluginLayerEntry) => setPluginLayerMap((prev) => ({ ...prev, [pluginId]: entry })), - [], - ); - const removeLayers = React.useCallback( - (pluginId: string) => - setPluginLayerMap((prev) => { - const { [pluginId]: _, ...rest } = prev; - return rest; - }), - [], - ); - const addClickHandler = React.useCallback( - (pluginId: string, handler: (coordinate: [number, number]) => boolean) => - setClickHandlers((prev) => ({ ...prev, [pluginId]: handler })), - [], - ); - const removeClickHandler = React.useCallback( - (pluginId: string) => - setClickHandlers((prev) => { - const { [pluginId]: _, ...rest } = prev; - return rest; - }), - [], - ); - const addHoverHandler = React.useCallback( - (pluginId: string, handler: (coordinate: [number, number] | null) => void) => - setHoverHandlers((prev) => ({ ...prev, [pluginId]: handler })), - [], - ); - const removeHoverHandler = React.useCallback( - (pluginId: string) => - setHoverHandlers((prev) => { - const { [pluginId]: _, ...rest } = prev; - return rest; - }), - [], - ); - - const pluginApi: ViewerPluginApi = React.useMemo( - () => ({ + // Notify host application when viewer state changes + React.useEffect(() => { + onViewerStateChange?.({ imageBounds, zInfo, tInfo, viewport, - viewState, setViewState: stableSetViewState, setZSlice, setTSlice, - addLayers, - removeLayers, - addClickHandler, - removeClickHandler, - addHoverHandler, - removeHoverHandler, - }), - [ - imageBounds, - zInfo, - tInfo, - viewport, - viewState, - stableSetViewState, - setZSlice, - setTSlice, - addLayers, - removeLayers, - addClickHandler, - removeClickHandler, - addHoverHandler, - removeHoverHandler, - ], - ); + }); + }, [imageBounds, zInfo, tInfo, viewport, stableSetViewState, setZSlice, setTSlice, onViewerStateChange]); return ( - + <> {children} - + ); } @@ -202,6 +120,11 @@ function VizarrViewerComponent({ sources = [], viewState: initialViewState, onViewStateChange, + onViewerStateChange, + additionalLayers, + pluginCursor, + onPluginClick, + onPluginHover, children, }: VizarrViewerProps) { const setSourceInfo = useSetAtom(sourceInfoAtom); @@ -274,7 +197,16 @@ function VizarrViewerComponent({ <> {redirectObj === null && ( - {children} + + {children} + )} {sourceError !== null && ( diff --git a/viewer/src/index.tsx b/viewer/src/index.tsx index fcfe1f8d..0b20015e 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -2,16 +2,11 @@ export { version } from "../../package.json"; export { default as theme } from "./theme"; export { default as Vizarr } from "./components/VizarrViewer"; -export type { VizarrViewerProps } from "./components/VizarrViewer"; +export type { VizarrViewerProps, ViewerInfo } from "./components/VizarrViewer"; export { createViewer } from "./api"; export type { VizarrViewer } from "./api"; -export type { ViewState, ImageLayerConfig } from "./state"; - -// Plugin context API -export { ViewerPluginContext, useViewerPlugin } from "./ViewerPluginContext"; -export type { ViewerPluginApi, PluginLayerEntry } from "./ViewerPluginContext"; -export type { ViewportSize } from "./state"; +export type { ViewState, ImageLayerConfig, ViewportSize } from "./state"; export { useViewState, ViewStateContext } from "./hooks"; From 6febf7d102eb190a645ee280f6775e8e83bb92e7 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Mon, 27 Apr 2026 10:52:03 +0200 Subject: [PATCH 24/32] fix: make saved ROIs list scrollable and bounded to viewport size --- roi-selector/src/RoiSelector.tsx | 7 +++-- roi-selector/src/components/SavedRoiList.tsx | 32 +++++++++++--------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index cc00ac8f..7f78c281 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -159,6 +159,9 @@ function RoiSelector({ top: "5px", padding: "4px 8px", minWidth: 210, + maxHeight: "calc(100vh - 20px)", + display: "flex", + flexDirection: "column", }} > @@ -170,8 +173,8 @@ function RoiSelector({ - - + + {(pendingRoi || editingRoiId) && ( - - {savedRois.map((roi) => ( - onToggleVisibility(roi.id)} - onGoTo={() => onGoTo(roi)} - onCopy={() => onCopy(roi)} - onEdit={() => onEdit(roi)} - onDelete={() => onDelete(roi.id)} - /> - ))} + + + {savedRois.map((roi) => ( + onToggleVisibility(roi.id)} + onGoTo={() => onGoTo(roi)} + onCopy={() => onCopy(roi)} + onEdit={() => onEdit(roi)} + onDelete={() => onDelete(roi.id)} + /> + ))} + + )} + + + {sourceUrl && ( + setImportDialogOpen(false)} + onImport={handleImport} + sourceUrl={sourceUrl} + /> + )} ); } diff --git a/roi-selector/src/components/ImportRoiDialog.tsx b/roi-selector/src/components/ImportRoiDialog.tsx new file mode 100644 index 00000000..8faac1ee --- /dev/null +++ b/roi-selector/src/components/ImportRoiDialog.tsx @@ -0,0 +1,124 @@ +import { + Box, + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Typography, +} from "@mui/material"; +import React, { useEffect, useState } from "react"; +import { type RoiTableInfo, discoverRoiTables } from "../importRois"; + +interface ImportRoiDialogProps { + open: boolean; + onClose: () => void; + onImport: (selectedTables: string[]) => void; + sourceUrl: string; +} + +export default function ImportRoiDialog({ + open, + onClose, + onImport, + sourceUrl, +}: ImportRoiDialogProps) { + const [tables, setTables] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !sourceUrl) return; + setLoading(true); + setError(null); + setTables([]); + setSelected(new Set()); + + discoverRoiTables(sourceUrl) + .then((discovered) => { + setTables(discovered); + // Select all by default + setSelected(new Set(discovered.map((t) => t.name))); + }) + .catch((err) => { + console.error("[ROI Import] Failed to discover ROI tables:", err); + setError("Failed to read tables from zarr store."); + }) + .finally(() => setLoading(false)); + }, [open, sourceUrl]); + + const handleToggle = (name: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const handleImport = () => { + onImport(Array.from(selected)); + onClose(); + }; + + return ( + + Import ROIs from Zarr + + {loading && ( + + + + )} + + {error && ( + + {error} + + )} + + {!loading && !error && tables.length === 0 && ( + + No ROI tables found in the zarr store. + + )} + + {!loading && + !error && + tables.map((table) => ( + handleToggle(table.name)} + /> + } + label={ + + {table.name} — {table.roiCount} ROI + {table.roiCount !== 1 ? "s" : ""} + {table.type ? ` (type: ${table.type})` : ""} + + } + /> + ))} + + + + + + + ); +} diff --git a/roi-selector/src/hooks/useRoiFields.ts b/roi-selector/src/hooks/useRoiFields.ts index 51ef73c7..b224ab39 100644 --- a/roi-selector/src/hooks/useRoiFields.ts +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -36,6 +36,7 @@ export interface UseRoiFieldsReturn { handleEditRoi: (roi: SavedRoi) => void; handleUpdateRoi: () => void; handleCancelEdit: () => void; + handleImportRois: (rois: SavedRoi[]) => void; } export interface UseRoiFieldsProps { @@ -249,6 +250,10 @@ export function useRoiFields({ setRoiName(""); }; + const handleImportRois = (rois: SavedRoi[]) => { + setSavedRois((prev) => [...prev, ...rois]); + }; + return { coords, onCoordChange, @@ -267,5 +272,6 @@ export function useRoiFields({ handleEditRoi, handleUpdateRoi, handleCancelEdit, + handleImportRois, }; } diff --git a/roi-selector/src/importRois.ts b/roi-selector/src/importRois.ts new file mode 100644 index 00000000..85247c82 --- /dev/null +++ b/roi-selector/src/importRois.ts @@ -0,0 +1,457 @@ +import * as zarr from "zarrita"; +import type { ImageBounds, RoiCorner, SavedRoi } from "./state"; +import { nextAvailableColor } from "./state"; + +/** Metadata about a discovered ROI table. */ +export interface RoiTableInfo { + name: string; + roiCount: number; + type: string; +} + +/** A single ROI in physical-unit coordinates (origin + length). */ +interface PhysicalRoi { + name: string; + originX: number; + originY: number; + lengthX: number; + lengthY: number; + originZ?: number; + lengthZ?: number; + originT?: number; + lengthT?: number; +} + +// ---- Store helpers ---- + +function openZarrLocation(sourceUrl: string): zarr.Location { + const url = new URL(sourceUrl); + const path = url.pathname as `/${string}`; + url.pathname = "/"; + const store = new zarr.FetchStore(url.href); + return new zarr.Location(store, path); +} + +function resolveAttrs(attrs: zarr.Attributes): zarr.Attributes { + if ("ome" in attrs) { + return attrs.ome as zarr.Attributes; + } + return attrs; +} + +// ---- Physical pixel sizes ---- + +interface PixelScales { + x: number; + y: number; + z?: number; + t?: number; +} + +async function getPixelScales( + location: zarr.Location, +): Promise { + const group = await zarr.open(location, { kind: "group" }); + const attrs = resolveAttrs(group.attrs); + + if (!("multiscales" in attrs)) { + throw new Error("Source zarr has no multiscales metadata"); + } + + const multiscales = attrs.multiscales as Array<{ + axes?: Array; + datasets: Array<{ + path: string; + coordinateTransformations?: Array< + { type: "scale"; scale: number[] } | { type: "translation"; translation: number[] } + >; + }>; + }>; + + const rawAxes = multiscales[0].axes ?? []; + const axes = rawAxes.map((a) => + typeof a === "string" + ? { name: a, type: a === "t" ? "time" : a === "c" ? "channel" : "space" } + : a, + ); + + const xIdx = axes.findIndex((a) => a.name === "x"); + const yIdx = axes.findIndex((a) => a.name === "y"); + const zIdx = axes.findIndex((a) => a.name === "z"); + const tIdx = axes.findIndex((a) => a.name === "t"); + + const transforms = multiscales[0].datasets[0]?.coordinateTransformations ?? []; + + let scaleX = 1; + let scaleY = 1; + let scaleZ: number | undefined; + let scaleT: number | undefined; + + for (const tr of transforms) { + if (tr.type === "scale") { + if (xIdx >= 0) scaleX = tr.scale[xIdx]; + if (yIdx >= 0) scaleY = tr.scale[yIdx]; + if (zIdx >= 0) scaleZ = tr.scale[zIdx]; + if (tIdx >= 0) scaleT = tr.scale[tIdx]; + } + } + + return { + x: scaleX, + y: scaleY, + ...(scaleZ !== undefined ? { z: scaleZ } : {}), + ...(scaleT !== undefined ? { t: scaleT } : {}), + }; +} + +// ---- Column matching ---- + +const ORIGIN_X_PATTERNS = ["x_micrometer", "x_origin", "origin_x", "x"]; +const ORIGIN_Y_PATTERNS = ["y_micrometer", "y_origin", "origin_y", "y"]; +const LENGTH_X_PATTERNS = [ + "len_x_micrometer", + "length_x", + "x_length", + "width", + "len_x", +]; +const LENGTH_Y_PATTERNS = [ + "len_y_micrometer", + "length_y", + "y_length", + "height", + "len_y", +]; +const ORIGIN_Z_PATTERNS = ["z_micrometer", "z_origin", "origin_z", "z"]; +const LENGTH_Z_PATTERNS = [ + "len_z_micrometer", + "length_z", + "z_length", + "depth", + "len_z", +]; +const ORIGIN_T_PATTERNS = ["t_micrometer", "t_origin", "origin_t", "t"]; +const LENGTH_T_PATTERNS = [ + "len_t_micrometer", + "length_t", + "t_length", + "duration", + "len_t", +]; + +function findColumnIndex(columnNames: string[], patterns: string[]): number { + for (const pattern of patterns) { + const idx = columnNames.findIndex( + (c) => c.toLowerCase() === pattern.toLowerCase(), + ); + if (idx >= 0) return idx; + } + return -1; +} + +// ---- Table discovery ---- + +/** + * Discover ROI tables available under `/tables` in the zarr store. + */ +export async function discoverRoiTables( + sourceUrl: string, +): Promise { + const location = openZarrLocation(sourceUrl); + + try { + const tablesLocation = location.resolve("tables"); + const tablesGroup = await zarr.open(tablesLocation, { kind: "group" }); + const tablesAttrs = resolveAttrs(tablesGroup.attrs); + + const tableNames: string[] = + (tablesAttrs.tables as string[] | undefined) ?? []; + + if (tableNames.length === 0) { + console.warn("[ROI Import] No tables listed in /tables group attributes"); + return []; + } + + const tables: RoiTableInfo[] = []; + + for (const name of tableNames) { + try { + const tableGroup = await zarr.open(tablesLocation.resolve(name), { + kind: "group", + }); + const tableAttrs = resolveAttrs(tableGroup.attrs); + const type = (tableAttrs.type as string) ?? ""; + + let roiCount = 0; + try { + const obsIndex = await zarr.open( + tablesLocation.resolve(`${name}/obs/_index`), + { kind: "array" }, + ); + roiCount = obsIndex.shape[0]; + } catch { + try { + const xArr = await zarr.open( + tablesLocation.resolve(`${name}/X`), + { kind: "array" }, + ); + roiCount = xArr.shape[0]; + } catch { + console.warn( + `[ROI Import] Could not determine ROI count for table "${name}"`, + ); + } + } + + tables.push({ name, roiCount, type }); + } catch (err) { + console.warn(`[ROI Import] Failed to read table "${name}":`, err); + } + } + + return tables; + } catch (err) { + console.warn("[ROI Import] Failed to open /tables group:", err); + return []; + } +} + +// ---- Table reading (AnnData zarr format) ---- + +async function readRoiTable( + tablesLocation: zarr.Location, + tableName: string, +): Promise { + // Read ROI names from obs/_index + let roiNames: string[] = []; + try { + const obsIndex = await zarr.open( + tablesLocation.resolve(`${tableName}/obs/_index`), + { kind: "array" }, + ); + const indexData = await zarr.get(obsIndex); + roiNames = Array.from(indexData.data as Iterable); + } catch { + console.warn( + `[ROI Import] Could not read obs/_index for table "${tableName}", will generate names`, + ); + } + + // Read column names from var/_index + let columnNames: string[]; + try { + const varIndex = await zarr.open( + tablesLocation.resolve(`${tableName}/var/_index`), + { kind: "array" }, + ); + const varData = await zarr.get(varIndex); + columnNames = Array.from(varData.data as Iterable); + } catch { + console.warn( + `[ROI Import] Could not read var/_index for table "${tableName}"`, + ); + return []; + } + + // Read X data matrix + let xShape: readonly number[]; + let xFlat: ArrayLike; + try { + const xArray = await zarr.open( + tablesLocation.resolve(`${tableName}/X`), + { kind: "array" }, + ); + const xData = await zarr.get(xArray); + xShape = xData.shape; + xFlat = xData.data as ArrayLike; + } catch { + console.warn( + `[ROI Import] Could not read X matrix for table "${tableName}"`, + ); + return []; + } + + // Identify required columns + const oxIdx = findColumnIndex(columnNames, ORIGIN_X_PATTERNS); + const oyIdx = findColumnIndex(columnNames, ORIGIN_Y_PATTERNS); + const lxIdx = findColumnIndex(columnNames, LENGTH_X_PATTERNS); + const lyIdx = findColumnIndex(columnNames, LENGTH_Y_PATTERNS); + + if (oxIdx < 0 || oyIdx < 0 || lxIdx < 0 || lyIdx < 0) { + console.warn( + `[ROI Import] Table "${tableName}" missing required columns. ` + + `Found: [${columnNames.join(", ")}]. ` + + `Need origin (x, y) and length (x, y) columns.`, + ); + return []; + } + + // Optional Z columns + const ozIdx = findColumnIndex(columnNames, ORIGIN_Z_PATTERNS); + const lzIdx = findColumnIndex(columnNames, LENGTH_Z_PATTERNS); + + // Optional T columns + const otIdx = findColumnIndex(columnNames, ORIGIN_T_PATTERNS); + const ltIdx = findColumnIndex(columnNames, LENGTH_T_PATTERNS); + + const nRows = xShape[0]; + const nCols = xShape[1]; + + if (roiNames.length === 0) { + roiNames = Array.from({ length: nRows }, (_, i) => `roi_${i}`); + } + + const rois: PhysicalRoi[] = []; + for (let i = 0; i < nRows; i++) { + const roi: PhysicalRoi = { + name: roiNames[i] ?? `roi_${i}`, + originX: Number(xFlat[i * nCols + oxIdx]), + originY: Number(xFlat[i * nCols + oyIdx]), + lengthX: Number(xFlat[i * nCols + lxIdx]), + lengthY: Number(xFlat[i * nCols + lyIdx]), + }; + + if (ozIdx >= 0 && lzIdx >= 0) { + roi.originZ = Number(xFlat[i * nCols + ozIdx]); + roi.lengthZ = Number(xFlat[i * nCols + lzIdx]); + } + + if (otIdx >= 0 && ltIdx >= 0) { + roi.originT = Number(xFlat[i * nCols + otIdx]); + roi.lengthT = Number(xFlat[i * nCols + ltIdx]); + } + + rois.push(roi); + } + + return rois; +} + +// ---- Main import function ---- + +/** + * Import ROIs from selected zarr tables, converting from physical-unit + * (origin + length) to pixel-unit (corner1, corner2) representation. + */ +export async function importRoisFromZarr( + sourceUrl: string, + selectedTables: string[], + imageBounds: ImageBounds, + existingRois: SavedRoi[], + zMax?: number | null, + tMax?: number | null, +): Promise { + const location = openZarrLocation(sourceUrl); + const tablesLocation = location.resolve("tables"); + + const pixelSizes = await getPixelScales(location); + + const importedRois: SavedRoi[] = []; + let allRois = [...existingRois]; + + for (const tableName of selectedTables) { + try { + const physicalRois = await readRoiTable(tablesLocation, tableName); + + for (const pRoi of physicalRois) { + // Convert physical origin+length → pixel corners + const pixelX1 = Math.round(pRoi.originX / pixelSizes.x); + const pixelY1 = Math.round(pRoi.originY / pixelSizes.y); + const pixelX2 = Math.round( + (pRoi.originX + pRoi.lengthX) / pixelSizes.x, + ); + const pixelY2 = Math.round( + (pRoi.originY + pRoi.lengthY) / pixelSizes.y, + ); + + // Warn about out-of-bounds + if ( + pixelX1 < imageBounds.xMin || + pixelY1 < imageBounds.yMin || + pixelX2 > imageBounds.xMax || + pixelY2 > imageBounds.yMax + ) { + console.warn( + `[ROI Import] "${tableName}/${pRoi.name}" extends outside image bounds ` + + `(${pixelX1},${pixelY1})→(${pixelX2},${pixelY2}), ` + + `image: (${imageBounds.xMin},${imageBounds.yMin})→(${imageBounds.xMax},${imageBounds.yMax}). Clamping.`, + ); + } + + const clamp = (v: number, lo: number, hi: number) => + Math.max(lo, Math.min(hi, v)); + + const corner1: RoiCorner = { + x: clamp(pixelX1, imageBounds.xMin, imageBounds.xMax), + y: clamp(pixelY1, imageBounds.yMin, imageBounds.yMax), + }; + const corner2: RoiCorner = { + x: clamp(pixelX2, imageBounds.xMin, imageBounds.xMax), + y: clamp(pixelY2, imageBounds.yMin, imageBounds.yMax), + }; + + // Z axis conversion + if ( + pRoi.originZ !== undefined && + pRoi.lengthZ !== undefined && + pixelSizes.z + ) { + const pz1 = Math.round(pRoi.originZ / pixelSizes.z); + const pz2 = Math.round( + (pRoi.originZ + pRoi.lengthZ) / pixelSizes.z, + ); + corner1.z = clamp(pz1, 0, zMax ?? pz1); + corner2.z = clamp(pz2, 0, zMax ?? pz2); + if (zMax != null && (pz1 > zMax || pz2 > zMax)) { + console.warn( + `[ROI Import] "${tableName}/${pRoi.name}" Z range (${pz1}–${pz2}) exceeds zMax (${zMax}). Clamping.`, + ); + } + } + + // T axis conversion + if ( + pRoi.originT !== undefined && + pRoi.lengthT !== undefined && + pixelSizes.t + ) { + const pt1 = Math.round(pRoi.originT / pixelSizes.t); + const pt2 = Math.round( + (pRoi.originT + pRoi.lengthT) / pixelSizes.t, + ); + corner1.t = clamp(pt1, 0, tMax ?? pt1); + corner2.t = clamp(pt2, 0, tMax ?? pt2); + if (tMax != null && (pt1 > tMax || pt2 > tMax)) { + console.warn( + `[ROI Import] "${tableName}/${pRoi.name}" T range (${pt1}–${pt2}) exceeds tMax (${tMax}). Clamping.`, + ); + } + } + + // Skip degenerate ROIs + if (corner1.x === corner2.x && corner1.y === corner2.y) { + console.warn( + `[ROI Import] "${tableName}/${pRoi.name}" has zero area, skipping.`, + ); + continue; + } + + const savedRoi: SavedRoi = { + id: Math.random().toString(36).slice(2), + name: `${tableName}/${pRoi.name}`, + corner1, + corner2, + color: nextAvailableColor(allRois), + visible: true, + }; + + importedRois.push(savedRoi); + allRois = [...allRois, savedRoi]; + } + } catch (err) { + console.error(`[ROI Import] Failed to import table "${tableName}":`, err); + } + } + + return importedRois; +} diff --git a/roi-selector/src/state.ts b/roi-selector/src/state.ts index 3fe46984..e40e8d22 100644 --- a/roi-selector/src/state.ts +++ b/roi-selector/src/state.ts @@ -240,6 +240,7 @@ export function nextAvailableColor(existingRois: SavedRoi[]): [number, number, n /** Viewer information passed from the host application. */ export interface ViewerInfo { + sourceUrl: string; imageBounds: ImageBounds | null; zInfo: { zValue: number; zMax: number } | null; tInfo: { tValue: number; tMax: number } | null; diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index 84aa9f7a..48cceb8f 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -30,6 +30,7 @@ import Viewer from "./Viewer"; /** Viewer state snapshot exposed to the host application via onViewerStateChange. */ export interface ViewerInfo { + sourceUrl: string; imageBounds: { xMin: number; yMin: number; xMax: number; yMax: number; spatialUnit: string } | null; zInfo: { zValue: number; zMax: number } | null; tInfo: { tValue: number; tMax: number } | null; @@ -57,6 +58,7 @@ export interface VizarrViewerProps { * and renders + + children. */ function ViewerBridge({ + sourceUrls, onViewStateChange, onViewerStateChange, additionalLayers = [], @@ -65,6 +67,7 @@ function ViewerBridge({ onPluginHover, children, }: { + sourceUrls: string[]; onViewStateChange?: (viewState: ViewState) => void; onViewerStateChange?: (info: ViewerInfo) => void; additionalLayers?: Layer[]; @@ -92,6 +95,7 @@ function ViewerBridge({ // Notify host application when viewer state changes React.useEffect(() => { onViewerStateChange?.({ + sourceUrl: sourceUrls[0] ?? "", imageBounds, zInfo, tInfo, @@ -100,7 +104,7 @@ function ViewerBridge({ setZSlice, setTSlice, }); - }, [imageBounds, zInfo, tInfo, viewport, stableSetViewState, setZSlice, setTSlice, onViewerStateChange]); + }, [sourceUrls, imageBounds, zInfo, tInfo, viewport, stableSetViewState, setZSlice, setTSlice, onViewerStateChange]); return ( <> @@ -198,6 +202,7 @@ function VizarrViewerComponent({ {redirectObj === null && ( Date: Fri, 17 Apr 2026 16:29:50 +0200 Subject: [PATCH 27/32] feat: add tooltip for import roi btn --- roi-selector/src/RoiSelector.tsx | 36 +++++++++++++++++--------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/roi-selector/src/RoiSelector.tsx b/roi-selector/src/RoiSelector.tsx index 2c6a46e5..2fa3da8f 100644 --- a/roi-selector/src/RoiSelector.tsx +++ b/roi-selector/src/RoiSelector.tsx @@ -234,23 +234,25 @@ function RoiSelector({ /> {sourceUrl && ( - + + + )} Date: Fri, 17 Apr 2026 16:56:49 +0200 Subject: [PATCH 28/32] fix: load imported roi names correctly --- roi-selector/src/importRois.ts | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/roi-selector/src/importRois.ts b/roi-selector/src/importRois.ts index 85247c82..52c62778 100644 --- a/roi-selector/src/importRois.ts +++ b/roi-selector/src/importRois.ts @@ -222,33 +222,49 @@ async function readRoiTable( tablesLocation: zarr.Location, tableName: string, ): Promise { - // Read ROI names from obs/_index + // Read ROI names from obs index column. let roiNames: string[] = []; try { + const obsGroup = await zarr.open( + tablesLocation.resolve(`${tableName}/obs`), + { kind: "group" }, + ); + const indexColumnName = obsGroup.attrs._index as string | undefined; + if (!indexColumnName) { + throw new Error("obs group has no _index attribute"); + } const obsIndex = await zarr.open( - tablesLocation.resolve(`${tableName}/obs/_index`), + tablesLocation.resolve(`${tableName}/obs/${indexColumnName}`), { kind: "array" }, ); const indexData = await zarr.get(obsIndex); roiNames = Array.from(indexData.data as Iterable); } catch { console.warn( - `[ROI Import] Could not read obs/_index for table "${tableName}", will generate names`, + `[ROI Import] Could not read obs index for table "${tableName}", will generate names`, ); } - // Read column names from var/_index + // Read column names from var index column (same AnnData convention as obs). let columnNames: string[]; try { + const varGroup = await zarr.open( + tablesLocation.resolve(`${tableName}/var`), + { kind: "group" }, + ); + const indexColumnName = varGroup.attrs._index as string | undefined; + if (!indexColumnName) { + throw new Error("var group has no _index attribute"); + } const varIndex = await zarr.open( - tablesLocation.resolve(`${tableName}/var/_index`), + tablesLocation.resolve(`${tableName}/var/${indexColumnName}`), { kind: "array" }, ); const varData = await zarr.get(varIndex); columnNames = Array.from(varData.data as Iterable); } catch { console.warn( - `[ROI Import] Could not read var/_index for table "${tableName}"`, + `[ROI Import] Could not read var index for table "${tableName}"`, ); return []; } From 20c45c0cb03e8a77999a20342438d3cfa11a79da Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Mon, 27 Apr 2026 15:17:38 +0200 Subject: [PATCH 29/32] fix: removed unnecessary conversion in inport time --- roi-selector/src/importRois.ts | 140 ++++++++------------------------- 1 file changed, 31 insertions(+), 109 deletions(-) diff --git a/roi-selector/src/importRois.ts b/roi-selector/src/importRois.ts index 52c62778..3549f4dd 100644 --- a/roi-selector/src/importRois.ts +++ b/roi-selector/src/importRois.ts @@ -39,71 +39,6 @@ function resolveAttrs(attrs: zarr.Attributes): zarr.Attributes { return attrs; } -// ---- Physical pixel sizes ---- - -interface PixelScales { - x: number; - y: number; - z?: number; - t?: number; -} - -async function getPixelScales( - location: zarr.Location, -): Promise { - const group = await zarr.open(location, { kind: "group" }); - const attrs = resolveAttrs(group.attrs); - - if (!("multiscales" in attrs)) { - throw new Error("Source zarr has no multiscales metadata"); - } - - const multiscales = attrs.multiscales as Array<{ - axes?: Array; - datasets: Array<{ - path: string; - coordinateTransformations?: Array< - { type: "scale"; scale: number[] } | { type: "translation"; translation: number[] } - >; - }>; - }>; - - const rawAxes = multiscales[0].axes ?? []; - const axes = rawAxes.map((a) => - typeof a === "string" - ? { name: a, type: a === "t" ? "time" : a === "c" ? "channel" : "space" } - : a, - ); - - const xIdx = axes.findIndex((a) => a.name === "x"); - const yIdx = axes.findIndex((a) => a.name === "y"); - const zIdx = axes.findIndex((a) => a.name === "z"); - const tIdx = axes.findIndex((a) => a.name === "t"); - - const transforms = multiscales[0].datasets[0]?.coordinateTransformations ?? []; - - let scaleX = 1; - let scaleY = 1; - let scaleZ: number | undefined; - let scaleT: number | undefined; - - for (const tr of transforms) { - if (tr.type === "scale") { - if (xIdx >= 0) scaleX = tr.scale[xIdx]; - if (yIdx >= 0) scaleY = tr.scale[yIdx]; - if (zIdx >= 0) scaleZ = tr.scale[zIdx]; - if (tIdx >= 0) scaleT = tr.scale[tIdx]; - } - } - - return { - x: scaleX, - y: scaleY, - ...(scaleZ !== undefined ? { z: scaleZ } : {}), - ...(scaleT !== undefined ? { t: scaleT } : {}), - }; -} - // ---- Column matching ---- const ORIGIN_X_PATTERNS = ["x_micrometer", "x_origin", "origin_x", "x"]; @@ -346,8 +281,7 @@ async function readRoiTable( // ---- Main import function ---- /** - * Import ROIs from selected zarr tables, converting from physical-unit - * (origin + length) to pixel-unit (corner1, corner2) representation. + * Import ROIs from selected zarr tables. */ export async function importRoisFromZarr( sourceUrl: string, @@ -360,8 +294,6 @@ export async function importRoisFromZarr( const location = openZarrLocation(sourceUrl); const tablesLocation = location.resolve("tables"); - const pixelSizes = await getPixelScales(location); - const importedRois: SavedRoi[] = []; let allRois = [...existingRois]; @@ -370,26 +302,22 @@ export async function importRoisFromZarr( const physicalRois = await readRoiTable(tablesLocation, tableName); for (const pRoi of physicalRois) { - // Convert physical origin+length → pixel corners - const pixelX1 = Math.round(pRoi.originX / pixelSizes.x); - const pixelY1 = Math.round(pRoi.originY / pixelSizes.y); - const pixelX2 = Math.round( - (pRoi.originX + pRoi.lengthX) / pixelSizes.x, - ); - const pixelY2 = Math.round( - (pRoi.originY + pRoi.lengthY) / pixelSizes.y, - ); + // Physical origin+length → physical corners (no conversion needed) + const x1 = pRoi.originX; + const y1 = pRoi.originY; + const x2 = pRoi.originX + pRoi.lengthX; + const y2 = pRoi.originY + pRoi.lengthY; // Warn about out-of-bounds if ( - pixelX1 < imageBounds.xMin || - pixelY1 < imageBounds.yMin || - pixelX2 > imageBounds.xMax || - pixelY2 > imageBounds.yMax + x1 < imageBounds.xMin || + y1 < imageBounds.yMin || + x2 > imageBounds.xMax || + y2 > imageBounds.yMax ) { console.warn( `[ROI Import] "${tableName}/${pRoi.name}" extends outside image bounds ` + - `(${pixelX1},${pixelY1})→(${pixelX2},${pixelY2}), ` + + `(${x1},${y1})→(${x2},${y2}), ` + `image: (${imageBounds.xMin},${imageBounds.yMin})→(${imageBounds.xMax},${imageBounds.yMax}). Clamping.`, ); } @@ -398,48 +326,42 @@ export async function importRoisFromZarr( Math.max(lo, Math.min(hi, v)); const corner1: RoiCorner = { - x: clamp(pixelX1, imageBounds.xMin, imageBounds.xMax), - y: clamp(pixelY1, imageBounds.yMin, imageBounds.yMax), + x: clamp(x1, imageBounds.xMin, imageBounds.xMax), + y: clamp(y1, imageBounds.yMin, imageBounds.yMax), }; const corner2: RoiCorner = { - x: clamp(pixelX2, imageBounds.xMin, imageBounds.xMax), - y: clamp(pixelY2, imageBounds.yMin, imageBounds.yMax), + x: clamp(x2, imageBounds.xMin, imageBounds.xMax), + y: clamp(y2, imageBounds.yMin, imageBounds.yMax), }; - // Z axis conversion + // Z axis (still index-based, no physical conversion) if ( pRoi.originZ !== undefined && - pRoi.lengthZ !== undefined && - pixelSizes.z + pRoi.lengthZ !== undefined ) { - const pz1 = Math.round(pRoi.originZ / pixelSizes.z); - const pz2 = Math.round( - (pRoi.originZ + pRoi.lengthZ) / pixelSizes.z, - ); - corner1.z = clamp(pz1, 0, zMax ?? pz1); - corner2.z = clamp(pz2, 0, zMax ?? pz2); - if (zMax != null && (pz1 > zMax || pz2 > zMax)) { + const z1 = Math.round(pRoi.originZ); + const z2 = Math.round(pRoi.originZ + pRoi.lengthZ); + corner1.z = clamp(z1, 0, zMax ?? z1); + corner2.z = clamp(z2, 0, zMax ?? z2); + if (zMax != null && (z1 > zMax || z2 > zMax)) { console.warn( - `[ROI Import] "${tableName}/${pRoi.name}" Z range (${pz1}–${pz2}) exceeds zMax (${zMax}). Clamping.`, + `[ROI Import] "${tableName}/${pRoi.name}" Z range (${z1}–${z2}) exceeds zMax (${zMax}). Clamping.`, ); } } - // T axis conversion + // T axis (still index-based, no physical conversion) if ( pRoi.originT !== undefined && - pRoi.lengthT !== undefined && - pixelSizes.t + pRoi.lengthT !== undefined ) { - const pt1 = Math.round(pRoi.originT / pixelSizes.t); - const pt2 = Math.round( - (pRoi.originT + pRoi.lengthT) / pixelSizes.t, - ); - corner1.t = clamp(pt1, 0, tMax ?? pt1); - corner2.t = clamp(pt2, 0, tMax ?? pt2); - if (tMax != null && (pt1 > tMax || pt2 > tMax)) { + const t1 = Math.round(pRoi.originT); + const t2 = Math.round(pRoi.originT + pRoi.lengthT); + corner1.t = clamp(t1, 0, tMax ?? t1); + corner2.t = clamp(t2, 0, tMax ?? t2); + if (tMax != null && (t1 > tMax || t2 > tMax)) { console.warn( - `[ROI Import] "${tableName}/${pRoi.name}" T range (${pt1}–${pt2}) exceeds tMax (${tMax}). Clamping.`, + `[ROI Import] "${tableName}/${pRoi.name}" T range (${t1}–${t2}) exceeds tMax (${tMax}). Clamping.`, ); } } From 6a64637f53e0b6417be718d0d3d97c6aa5865f34 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Mon, 18 May 2026 17:17:27 +0200 Subject: [PATCH 30/32] refactor: now exclude non-roi tables, masking roi non selected by default --- pnpm-lock.yaml | 10 +++++----- roi-selector/src/components/ImportRoiDialog.tsx | 10 ++++++++-- roi-selector/src/importRois.ts | 5 ++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1540c04c..5c6f8617 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,7 +84,7 @@ importers: version: 7.3.4(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) deck.gl: specifier: ~9.0.0 - version: 9.0.41(@arcgis/core@4.32.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 9.0.41(@arcgis/core@4.32.8(@lit/context@1.1.6))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.2.0 version: 18.3.1 @@ -103,16 +103,16 @@ importers: version: 18.3.5(@types/react@18.3.18) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.3.4(vite@6.2.7(@types/node@24.3.0)) + version: 4.3.4(vite@6.2.7(@types/node@24.3.0)(yaml@2.8.2)) typescript: specifier: ^5.8.2 version: 5.8.2 vite: specifier: ^6.2.7 version: 6.2.7(@types/node@24.3.0)(yaml@2.8.2) - vitest: - specifier: ^4.0.15 - version: 4.0.15(@types/node@24.3.0)(yaml@2.8.2) + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(@types/node@24.3.0)(rollup@4.40.1)(typescript@5.8.2)(vite@6.2.7(@types/node@24.3.0)(yaml@2.8.2)) sites/app: dependencies: diff --git a/roi-selector/src/components/ImportRoiDialog.tsx b/roi-selector/src/components/ImportRoiDialog.tsx index 8faac1ee..f619bec7 100644 --- a/roi-selector/src/components/ImportRoiDialog.tsx +++ b/roi-selector/src/components/ImportRoiDialog.tsx @@ -41,8 +41,14 @@ export default function ImportRoiDialog({ discoverRoiTables(sourceUrl) .then((discovered) => { setTables(discovered); - // Select all by default - setSelected(new Set(discovered.map((t) => t.name))); + // Select only roi_table by default (not masking_roi_table) + setSelected( + new Set( + discovered + .filter((t) => t.type === "roi_table") + .map((t) => t.name), + ), + ); }) .catch((err) => { console.error("[ROI Import] Failed to discover ROI tables:", err); diff --git a/roi-selector/src/importRois.ts b/roi-selector/src/importRois.ts index 3549f4dd..fed31d4d 100644 --- a/roi-selector/src/importRois.ts +++ b/roi-selector/src/importRois.ts @@ -138,13 +138,16 @@ export async function discoverRoiTables( } } + console.log(`[ROI Import] Table "${name}" has type: "${type}"`); tables.push({ name, roiCount, type }); } catch (err) { console.warn(`[ROI Import] Failed to read table "${name}":`, err); } } - return tables; + return tables.filter( + (t) => t.type === "roi_table" || t.type === "masking_roi_table", + ); } catch (err) { console.warn("[ROI Import] Failed to open /tables group:", err); return []; From 378e670244793e836468ee6e23bd1283da4eb830 Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 27 May 2026 10:17:49 +0200 Subject: [PATCH 31/32] fix: correct for lint and check to pass --- .../src/components/ImportRoiDialog.tsx | 32 +---- roi-selector/src/importRois.ts | 131 ++++-------------- 2 files changed, 31 insertions(+), 132 deletions(-) diff --git a/roi-selector/src/components/ImportRoiDialog.tsx b/roi-selector/src/components/ImportRoiDialog.tsx index f619bec7..d16d3ecd 100644 --- a/roi-selector/src/components/ImportRoiDialog.tsx +++ b/roi-selector/src/components/ImportRoiDialog.tsx @@ -20,12 +20,7 @@ interface ImportRoiDialogProps { sourceUrl: string; } -export default function ImportRoiDialog({ - open, - onClose, - onImport, - sourceUrl, -}: ImportRoiDialogProps) { +export default function ImportRoiDialog({ open, onClose, onImport, sourceUrl }: ImportRoiDialogProps) { const [tables, setTables] = useState([]); const [selected, setSelected] = useState>(new Set()); const [loading, setLoading] = useState(false); @@ -42,13 +37,7 @@ export default function ImportRoiDialog({ .then((discovered) => { setTables(discovered); // Select only roi_table by default (not masking_roi_table) - setSelected( - new Set( - discovered - .filter((t) => t.type === "roi_table") - .map((t) => t.name), - ), - ); + setSelected(new Set(discovered.filter((t) => t.type === "roi_table").map((t) => t.name))); }) .catch((err) => { console.error("[ROI Import] Failed to discover ROI tables:", err); @@ -88,9 +77,7 @@ export default function ImportRoiDialog({ )} {!loading && !error && tables.length === 0 && ( - - No ROI tables found in the zarr store. - + No ROI tables found in the zarr store. )} {!loading && @@ -99,12 +86,7 @@ export default function ImportRoiDialog({ handleToggle(table.name)} - /> - } + control={ handleToggle(table.name)} />} label={ {table.name} — {table.roiCount} ROI @@ -117,11 +99,7 @@ export default function ImportRoiDialog({ - diff --git a/roi-selector/src/importRois.ts b/roi-selector/src/importRois.ts index fed31d4d..9440553d 100644 --- a/roi-selector/src/importRois.ts +++ b/roi-selector/src/importRois.ts @@ -43,42 +43,16 @@ function resolveAttrs(attrs: zarr.Attributes): zarr.Attributes { const ORIGIN_X_PATTERNS = ["x_micrometer", "x_origin", "origin_x", "x"]; const ORIGIN_Y_PATTERNS = ["y_micrometer", "y_origin", "origin_y", "y"]; -const LENGTH_X_PATTERNS = [ - "len_x_micrometer", - "length_x", - "x_length", - "width", - "len_x", -]; -const LENGTH_Y_PATTERNS = [ - "len_y_micrometer", - "length_y", - "y_length", - "height", - "len_y", -]; +const LENGTH_X_PATTERNS = ["len_x_micrometer", "length_x", "x_length", "width", "len_x"]; +const LENGTH_Y_PATTERNS = ["len_y_micrometer", "length_y", "y_length", "height", "len_y"]; const ORIGIN_Z_PATTERNS = ["z_micrometer", "z_origin", "origin_z", "z"]; -const LENGTH_Z_PATTERNS = [ - "len_z_micrometer", - "length_z", - "z_length", - "depth", - "len_z", -]; +const LENGTH_Z_PATTERNS = ["len_z_micrometer", "length_z", "z_length", "depth", "len_z"]; const ORIGIN_T_PATTERNS = ["t_micrometer", "t_origin", "origin_t", "t"]; -const LENGTH_T_PATTERNS = [ - "len_t_micrometer", - "length_t", - "t_length", - "duration", - "len_t", -]; +const LENGTH_T_PATTERNS = ["len_t_micrometer", "length_t", "t_length", "duration", "len_t"]; function findColumnIndex(columnNames: string[], patterns: string[]): number { for (const pattern of patterns) { - const idx = columnNames.findIndex( - (c) => c.toLowerCase() === pattern.toLowerCase(), - ); + const idx = columnNames.findIndex((c) => c.toLowerCase() === pattern.toLowerCase()); if (idx >= 0) return idx; } return -1; @@ -89,9 +63,7 @@ function findColumnIndex(columnNames: string[], patterns: string[]): number { /** * Discover ROI tables available under `/tables` in the zarr store. */ -export async function discoverRoiTables( - sourceUrl: string, -): Promise { +export async function discoverRoiTables(sourceUrl: string): Promise { const location = openZarrLocation(sourceUrl); try { @@ -99,8 +71,7 @@ export async function discoverRoiTables( const tablesGroup = await zarr.open(tablesLocation, { kind: "group" }); const tablesAttrs = resolveAttrs(tablesGroup.attrs); - const tableNames: string[] = - (tablesAttrs.tables as string[] | undefined) ?? []; + const tableNames: string[] = (tablesAttrs.tables as string[] | undefined) ?? []; if (tableNames.length === 0) { console.warn("[ROI Import] No tables listed in /tables group attributes"); @@ -119,22 +90,14 @@ export async function discoverRoiTables( let roiCount = 0; try { - const obsIndex = await zarr.open( - tablesLocation.resolve(`${name}/obs/_index`), - { kind: "array" }, - ); + const obsIndex = await zarr.open(tablesLocation.resolve(`${name}/obs/_index`), { kind: "array" }); roiCount = obsIndex.shape[0]; } catch { try { - const xArr = await zarr.open( - tablesLocation.resolve(`${name}/X`), - { kind: "array" }, - ); + const xArr = await zarr.open(tablesLocation.resolve(`${name}/X`), { kind: "array" }); roiCount = xArr.shape[0]; } catch { - console.warn( - `[ROI Import] Could not determine ROI count for table "${name}"`, - ); + console.warn(`[ROI Import] Could not determine ROI count for table "${name}"`); } } @@ -145,9 +108,7 @@ export async function discoverRoiTables( } } - return tables.filter( - (t) => t.type === "roi_table" || t.type === "masking_roi_table", - ); + return tables.filter((t) => t.type === "roi_table" || t.type === "masking_roi_table"); } catch (err) { console.warn("[ROI Import] Failed to open /tables group:", err); return []; @@ -156,54 +117,35 @@ export async function discoverRoiTables( // ---- Table reading (AnnData zarr format) ---- -async function readRoiTable( - tablesLocation: zarr.Location, - tableName: string, -): Promise { +async function readRoiTable(tablesLocation: zarr.Location, tableName: string): Promise { // Read ROI names from obs index column. let roiNames: string[] = []; try { - const obsGroup = await zarr.open( - tablesLocation.resolve(`${tableName}/obs`), - { kind: "group" }, - ); + const obsGroup = await zarr.open(tablesLocation.resolve(`${tableName}/obs`), { kind: "group" }); const indexColumnName = obsGroup.attrs._index as string | undefined; if (!indexColumnName) { throw new Error("obs group has no _index attribute"); } - const obsIndex = await zarr.open( - tablesLocation.resolve(`${tableName}/obs/${indexColumnName}`), - { kind: "array" }, - ); + const obsIndex = await zarr.open(tablesLocation.resolve(`${tableName}/obs/${indexColumnName}`), { kind: "array" }); const indexData = await zarr.get(obsIndex); roiNames = Array.from(indexData.data as Iterable); } catch { - console.warn( - `[ROI Import] Could not read obs index for table "${tableName}", will generate names`, - ); + console.warn(`[ROI Import] Could not read obs index for table "${tableName}", will generate names`); } // Read column names from var index column (same AnnData convention as obs). let columnNames: string[]; try { - const varGroup = await zarr.open( - tablesLocation.resolve(`${tableName}/var`), - { kind: "group" }, - ); + const varGroup = await zarr.open(tablesLocation.resolve(`${tableName}/var`), { kind: "group" }); const indexColumnName = varGroup.attrs._index as string | undefined; if (!indexColumnName) { throw new Error("var group has no _index attribute"); } - const varIndex = await zarr.open( - tablesLocation.resolve(`${tableName}/var/${indexColumnName}`), - { kind: "array" }, - ); + const varIndex = await zarr.open(tablesLocation.resolve(`${tableName}/var/${indexColumnName}`), { kind: "array" }); const varData = await zarr.get(varIndex); columnNames = Array.from(varData.data as Iterable); } catch { - console.warn( - `[ROI Import] Could not read var index for table "${tableName}"`, - ); + console.warn(`[ROI Import] Could not read var index for table "${tableName}"`); return []; } @@ -211,17 +153,12 @@ async function readRoiTable( let xShape: readonly number[]; let xFlat: ArrayLike; try { - const xArray = await zarr.open( - tablesLocation.resolve(`${tableName}/X`), - { kind: "array" }, - ); + const xArray = await zarr.open(tablesLocation.resolve(`${tableName}/X`), { kind: "array" }); const xData = await zarr.get(xArray); xShape = xData.shape; xFlat = xData.data as ArrayLike; } catch { - console.warn( - `[ROI Import] Could not read X matrix for table "${tableName}"`, - ); + console.warn(`[ROI Import] Could not read X matrix for table "${tableName}"`); return []; } @@ -233,9 +170,7 @@ async function readRoiTable( if (oxIdx < 0 || oyIdx < 0 || lxIdx < 0 || lyIdx < 0) { console.warn( - `[ROI Import] Table "${tableName}" missing required columns. ` + - `Found: [${columnNames.join(", ")}]. ` + - `Need origin (x, y) and length (x, y) columns.`, + `[ROI Import] Table "${tableName}" missing required columns. Found: [${columnNames.join(", ")}]. Need origin (x, y) and length (x, y) columns.`, ); return []; } @@ -312,12 +247,7 @@ export async function importRoisFromZarr( const y2 = pRoi.originY + pRoi.lengthY; // Warn about out-of-bounds - if ( - x1 < imageBounds.xMin || - y1 < imageBounds.yMin || - x2 > imageBounds.xMax || - y2 > imageBounds.yMax - ) { + if (x1 < imageBounds.xMin || y1 < imageBounds.yMin || x2 > imageBounds.xMax || y2 > imageBounds.yMax) { console.warn( `[ROI Import] "${tableName}/${pRoi.name}" extends outside image bounds ` + `(${x1},${y1})→(${x2},${y2}), ` + @@ -325,8 +255,7 @@ export async function importRoisFromZarr( ); } - const clamp = (v: number, lo: number, hi: number) => - Math.max(lo, Math.min(hi, v)); + const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const corner1: RoiCorner = { x: clamp(x1, imageBounds.xMin, imageBounds.xMax), @@ -338,10 +267,7 @@ export async function importRoisFromZarr( }; // Z axis (still index-based, no physical conversion) - if ( - pRoi.originZ !== undefined && - pRoi.lengthZ !== undefined - ) { + if (pRoi.originZ !== undefined && pRoi.lengthZ !== undefined) { const z1 = Math.round(pRoi.originZ); const z2 = Math.round(pRoi.originZ + pRoi.lengthZ); corner1.z = clamp(z1, 0, zMax ?? z1); @@ -354,10 +280,7 @@ export async function importRoisFromZarr( } // T axis (still index-based, no physical conversion) - if ( - pRoi.originT !== undefined && - pRoi.lengthT !== undefined - ) { + if (pRoi.originT !== undefined && pRoi.lengthT !== undefined) { const t1 = Math.round(pRoi.originT); const t2 = Math.round(pRoi.originT + pRoi.lengthT); corner1.t = clamp(t1, 0, tMax ?? t1); @@ -371,9 +294,7 @@ export async function importRoisFromZarr( // Skip degenerate ROIs if (corner1.x === corner2.x && corner1.y === corner2.y) { - console.warn( - `[ROI Import] "${tableName}/${pRoi.name}" has zero area, skipping.`, - ); + console.warn(`[ROI Import] "${tableName}/${pRoi.name}" has zero area, skipping.`); continue; } From 9e678bf7bde3665eb02f2eea1020bbedd997bdde Mon Sep 17 00:00:00 2001 From: Luca Anceschi Date: Wed, 27 May 2026 11:44:50 +0200 Subject: [PATCH 32/32] fix: build plugin before check --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 27935ae2..c116e69c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "pnpm --filter app dev", "lint": "biome ci", "fix": "biome check --write", - "check": "pnpm build:viewer && pnpm -r run check", + "check": "pnpm build:viewer && pnpm build:roi-selector && pnpm -r run check", "build:viewer": "pnpm --filter vizarr build", "build:roi-selector": "pnpm --filter @biongff/roi-selector build", "build:app": "pnpm --filter app build",