diff --git a/package.json b/package.json index 5fce2f20..c116e69c 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,12 @@ "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", - "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..5c6f8617 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,8 +65,60 @@ 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) + deck.gl: + specifier: ~9.0.0 + 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 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + zarrita: + specifier: 0.5.0 + version: 0.5.0 + 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)(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) + 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: + '@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 36109034..aa50a8cc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ packages: - 'viewer' + - 'roi-selector' - 'sites/*' + diff --git a/roi-selector/package.json b/roi-selector/package.json new file mode 100644 index 00000000..b216d0ba --- /dev/null +++ b/roi-selector/package.json @@ -0,0 +1,44 @@ +{ + "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:*", + "zarrita": "0.5.0" + }, + "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", + "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..2fa3da8f --- /dev/null +++ b/roi-selector/src/RoiSelector.tsx @@ -0,0 +1,296 @@ +import { CropFree, FileDownload } from "@mui/icons-material"; +import { Box, Button, Collapse, IconButton, Snackbar, Tooltip, Typography } from "@mui/material"; +import type React from "react"; +import { useState } from "react"; + +import ImportRoiDialog from "./components/ImportRoiDialog"; +import RoiCoordinateFields from "./components/RoiCoordinateFields"; +import RoiDrawControls from "./components/RoiDrawControls"; +import SavedRoiList from "./components/SavedRoiList"; +import { useRoiFields } from "./hooks/useRoiFields"; +import { importRoisFromZarr } from "./importRois"; +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: + * + * 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. + * + * 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({ + roiDrawState, + setRoiDrawState, + savedRois, + setSavedRois, + pendingRoi, + setPendingRoi, + viewerInfo, +}: RoiSelectorProps) { + const { imageBounds, zInfo, tInfo, viewport, setViewState, setZSlice, setTSlice } = viewerInfo; + + const { + coords, + onCoordChange, + roiName, + onRoiNameChange, + hasZAxis, + hasTAxis, + isDrawing, + editingRoiId, + handleToggleDraw, + handleSaveRoi, + handleDiscardRoi, + handleDeleteRoi, + handleDeleteAllRois, + handleToggleVisibility, + handleEditRoi, + handleUpdateRoi, + handleCancelEdit, + handleImportRois, + } = 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); + const [importDialogOpen, setImportDialogOpen] = useState(false); + + const sourceUrl = viewerInfo.sourceUrl ?? ""; + + /** Navigate the viewer to a saved ROI (XY + Z) and make it visible. */ + const handleGoToSavedRoi = (roi: SavedRoi) => { + if (!viewport) return; + 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); + const availH = Math.max(viewport.height - 2 * padding, 1); + const zoom = Math.log2(Math.min(availW / roiWidth, availH / roiHeight)); + setViewState({ + zoom, + target: [(bounds.min.x + bounds.max.x) / 2, (bounds.min.y + bounds.max.y) / 2], + width: viewport.width, + height: viewport.height, + }); + 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 < 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) { + handleToggleVisibility(roi.id); + } + }; + + // ---- Clipboard ---- + const roiToPayload = (roi: SavedRoi): Record => { + const bounds = normalizeRoiBounds(roi); + 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; + } + if (hasTAxis && bounds.min.t !== undefined && bounds.max.t !== undefined) { + payload.t1 = bounds.min.t; + payload.t2 = bounds.max.t; + } + return payload; + }; + + const handleCopySingleRoi = (roi: SavedRoi) => { + navigator.clipboard.writeText(JSON.stringify(roiToPayload(roi), null, 2)).then(() => setSnackOpen(true)); + }; + + const handleCopyAllRois = () => { + navigator.clipboard.writeText(JSON.stringify(savedRois.map(roiToPayload), null, 2)).then(() => setSnackOpen(true)); + }; + + // ---- Import ROIs from zarr tables ---- + const handleImport = async (selectedTables: string[]) => { + if (!sourceUrl || !imageBounds) { + console.warn("[ROI Import] No source URL or image bounds available"); + return; + } + try { + const imported = await importRoisFromZarr( + sourceUrl, + selectedTables, + imageBounds, + savedRois, + zInfo?.zMax, + tInfo?.tMax, + ); + if (imported.length > 0) { + handleImportRois(imported); + console.log(`[ROI Import] Imported ${imported.length} ROI(s)`); + } else { + console.warn("[ROI Import] No ROIs were imported from the selected tables"); + } + } catch (err) { + console.error("[ROI Import] Import failed:", err); + } + }; + + // ---- Render ---- + return ( + + + setOpen((prev) => !prev)} sx={{ color: "#fff" }}> + + + ROI Selection + + + + + + + {(pendingRoi || editingRoiId) && ( + + )} + + + + {sourceUrl && ( + + + + )} + + setRoiMenuOpen((prev) => !prev)} + onToggleVisibility={handleToggleVisibility} + onGoTo={handleGoToSavedRoi} + onCopy={handleCopySingleRoi} + onEdit={handleEditRoi} + onDelete={handleDeleteRoi} + onCopyAll={handleCopyAllRois} + onDeleteAll={handleDeleteAllRois} + /> + + + + setSnackOpen(false)} + message="ROI coordinates copied!" + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + /> + + {sourceUrl && ( + setImportDialogOpen(false)} + onImport={handleImport} + sourceUrl={sourceUrl} + /> + )} + + ); +} + +export default RoiSelector; diff --git a/roi-selector/src/components/ImportRoiDialog.tsx b/roi-selector/src/components/ImportRoiDialog.tsx new file mode 100644 index 00000000..d16d3ecd --- /dev/null +++ b/roi-selector/src/components/ImportRoiDialog.tsx @@ -0,0 +1,108 @@ +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 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); + 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/components/RoiCoordinateFields.tsx b/roi-selector/src/components/RoiCoordinateFields.tsx new file mode 100644 index 00000000..de1f1490 --- /dev/null +++ b/roi-selector/src/components/RoiCoordinateFields.tsx @@ -0,0 +1,208 @@ +import { Grid, TextField, Typography } from "@mui/material"; +import React from "react"; + +import type { CoordKey, CoordValues } from "../hooks/useRoiFields"; +import type { ImageBounds } from "../state"; + +interface RoiCoordinateFieldsProps { + coords: CoordValues; + onCoordChange: (key: CoordKey, value: string) => void; + roiName: string; + onRoiNameChange: (value: string) => void; + hasZAxis: boolean; + hasTAxis: boolean; + zInfo: { zMax: number } | null; + tInfo: { tMax: number } | null; + imageBounds: ImageBounds | null; +} + +const fieldSx = { color: "#fff", fontSize: 12 }; + +/** Format a physical coordinate for display in labels (up to 2 dp). */ +const fmt = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2)); + +export default function RoiCoordinateFields({ + coords, + onCoordChange, + roiName, + onRoiNameChange, + hasZAxis, + hasTAxis, + zInfo, + tInfo, + imageBounds, +}: RoiCoordinateFieldsProps) { + const unit = imageBounds?.spatialUnit || ""; + const unitLabel = unit ? ` (${unit})` : ""; + + return ( + <> + {/* ---- Unit indicator ---- */} + {unit && ( + + Coordinates in {unit} + + )} + + {/* ---- 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₁) + + + + onCoordChange("x1", e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: imageBounds?.xMin, max: imageBounds?.xMax, step: "any" }, + }} + /> + + + onCoordChange("y1", e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: imageBounds?.yMin, max: imageBounds?.yMax, step: "any" }, + }} + /> + + + + {/* ---- Bottom-right ---- */} + + Bottom-right (x₂, y₂) + + + + onCoordChange("x2", e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: imageBounds?.xMin, max: imageBounds?.xMax, step: "any" }, + }} + /> + + + onCoordChange("y2", e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: imageBounds?.yMin, max: imageBounds?.yMax, step: "any" }, + }} + /> + + + + {/* ---- Z range (only when data has a Z axis) ---- */} + {hasZAxis && zInfo && ( + <> + + Z range (slice) + + + + onCoordChange("z1", e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: zInfo.zMax }, + }} + /> + + + onCoordChange("z2", e.target.value)} + fullWidth + slotProps={{ + input: { sx: fieldSx }, + htmlInput: { min: 0, max: zInfo.zMax }, + }} + /> + + + + )} + + {/* ---- 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/RoiDrawControls.tsx b/roi-selector/src/components/RoiDrawControls.tsx new file mode 100644 index 00000000..6e47eeb7 --- /dev/null +++ b/roi-selector/src/components/RoiDrawControls.tsx @@ -0,0 +1,79 @@ +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..9de1f8f4 --- /dev/null +++ b/roi-selector/src/components/SavedRoiItem.tsx @@ -0,0 +1,168 @@ +import { ContentCopy, Delete, Edit, MyLocation, VisibilityOff } from "@mui/icons-material"; +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"; + +/** Format a physical coordinate for compact display (up to 2 dp). */ +const fmt = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2)); + +interface SavedRoiItemProps { + roi: SavedRoi; + hasZAxis: boolean; + hasTAxis: boolean; + isEditing: boolean; + onToggleVisibility: () => void; + onGoTo: () => void; + onCopy: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export default function SavedRoiItem({ + roi, + hasZAxis, + hasTAxis, + isEditing, + onToggleVisibility, + onGoTo, + onCopy, + onEdit, + onDelete, +}: SavedRoiItemProps) { + const bounds = normalizeRoiBounds(roi); + const [confirmOpen, setConfirmOpen] = useState(false); + + return ( + <> + 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} + + + ({fmt(bounds.min.x)}, {fmt(bounds.min.y)}) → ({fmt(bounds.max.x)}, {fmt(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 */} + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000..34051617 --- /dev/null +++ b/roi-selector/src/components/SavedRoiList.tsx @@ -0,0 +1,159 @@ +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"; + +interface SavedRoiListProps { + savedRois: SavedRoi[]; + hasZAxis: boolean; + hasTAxis: 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; + onDeleteAll: () => void; +} + +export default function SavedRoiList({ + savedRois, + hasZAxis, + hasTAxis, + editingRoiId, + roiMenuOpen, + onToggleOpen, + onToggleVisibility, + onGoTo, + onCopy, + onEdit, + onDelete, + onCopyAll, + onDeleteAll, +}: SavedRoiListProps) { + const [confirmDeleteAllOpen, setConfirmDeleteAllOpen] = useState(false); + + 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)} + /> + ))} + + + + + + + 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 new file mode 100644 index 00000000..b224ab39 --- /dev/null +++ b/roi-selector/src/hooks/useRoiFields.ts @@ -0,0 +1,277 @@ +import React, { useEffect, useRef, useState } from "react"; + +import { + type ImageBounds, + type PendingRoi, + type RoiDrawState, + type SavedRoi, + boundsToCoords, + clampToBounds, + coordsToRoi, + nextAvailableColor, + nextDefaultRoiName, + normalizeRoiBounds, +} from "../state"; + +export type CoordKey = "x1" | "y1" | "x2" | "y2" | "z1" | "z2" | "t1" | "t2"; +export type CoordValues = Record; + +export interface UseRoiFieldsReturn { + coords: CoordValues; + onCoordChange: (key: CoordKey, value: string) => void; + roiName: string; + onRoiNameChange: (value: string) => void; + // Derived state + hasZAxis: boolean; + hasTAxis: boolean; + isDrawing: boolean; + editingRoiId: string | null; + // Handlers + handleToggleDraw: () => void; + handleSaveRoi: () => void; + handleDiscardRoi: () => void; + handleDeleteRoi: (id: string) => void; + handleDeleteAllRois: () => void; + handleToggleVisibility: (id: string) => void; + handleEditRoi: (roi: SavedRoi) => void; + handleUpdateRoi: () => void; + handleCancelEdit: () => void; + handleImportRois: (rois: SavedRoi[]) => 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. + * + * 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({ + roiDrawState, + setRoiDrawState, + savedRois, + setSavedRois, + pendingRoi, + setPendingRoi, + imageBounds, + zInfo, + tInfo, +}: UseRoiFieldsProps): UseRoiFieldsReturn { + const [coords, setCoords] = useState({ + x1: "", + y1: "", + x2: "", + y2: "", + z1: "", + z2: "", + t1: "", + t2: "", + }); + + const [editingRoiId, setEditingRoiId] = useState(null); + const [roiName, setRoiName] = useState(""); + + const hasZAxis = zInfo !== null; + const hasTAxis = tInfo !== null; + const zMax = zInfo?.zMax ?? null; + const tMax = tInfo?.tMax ?? null; + + const isDrawing = roiDrawState !== null; + + // 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 normalized = normalizeRoiBounds(pendingRoi); + const clamped = imageBounds ? clampToBounds(normalized, imageBounds, zMax, tMax) : normalized; + setCoords(boundsToCoords(clamped) as CoordValues); + } + }, [pendingRoi, imageBounds, zMax, tMax]); + + // ---- Live sync: field changes → state (for overlay preview) ---- + const syncFieldsToPending = React.useCallback( + (next: CoordValues) => { + if (editingRoiId) { + setSavedRois((prev) => + prev.map((r) => { + if (r.id !== editingRoiId) return r; + const parsed = coordsToRoi(next, r); + return parsed ? { ...r, ...parsed } : r; + }), + ); + return; + } + + setPendingRoi((prev) => { + if (!prev) return prev; + const parsed = coordsToRoi(next, prev); + if (!parsed) return prev; + internalUpdate.current = true; + return parsed; + }); + }, + [editingRoiId, setSavedRois, setPendingRoi], + ); + + const onCoordChange = React.useCallback( + (key: CoordKey, value: string) => { + setCoords((prev) => { + // 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: { lo: imageBounds.xMin, hi: imageBounds.xMax }, + x2: { lo: imageBounds.xMin, hi: imageBounds.xMax }, + y1: { lo: imageBounds.yMin, hi: imageBounds.yMax }, + y2: { lo: imageBounds.yMin, hi: imageBounds.yMax }, + ...(zMax !== null ? { z1: { lo: 0, hi: zMax }, z2: { lo: 0, hi: zMax } } : {}), + ...(tMax !== null ? { t1: { lo: 0, hi: tMax }, t2: { lo: 0, hi: tMax } } : {}), + }; + const range = limits[key]; + if (range !== undefined) { + clamped = String(Math.max(range.lo, Math.min(num, range.hi))); + } + } + } + const next = { ...prev, [key]: clamped }; + syncFieldsToPending(next); + return next; + }); + }, + [syncFieldsToPending, imageBounds, zMax, tMax], + ); + + // ---- Draw-mode toggle ---- + const handleToggleDraw = () => { + if (isDrawing) { + setRoiDrawState(null); + } else { + setRoiDrawState("waiting-first"); + } + }; + + // ---- Save pending ROI ---- + const handleSaveRoi = () => { + if (!pendingRoi) return; + const raw = coordsToRoi(coords, pendingRoi); + if (!raw) return; + const bounds = imageBounds + ? clampToBounds(normalizeRoiBounds(raw), imageBounds, zMax, tMax) + : normalizeRoiBounds(raw); + 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); + + const handleDeleteRoi = (id: string) => { + setSavedRois((prev) => prev.filter((r) => r.id !== id)); + 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))); + }; + + const handleEditRoi = (roi: SavedRoi) => { + if (pendingRoi) setPendingRoi(null); + 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 = () => { + if (editOriginal.current && editingRoiId) { + const orig = editOriginal.current; + setSavedRois((prev) => prev.map((r) => (r.id === editingRoiId ? orig : r))); + } + editOriginal.current = null; + setEditingRoiId(null); + setRoiName(""); + }; + + const handleImportRois = (rois: SavedRoi[]) => { + setSavedRois((prev) => [...prev, ...rois]); + }; + + return { + coords, + onCoordChange, + roiName, + onRoiNameChange: setRoiName, + hasZAxis, + hasTAxis, + isDrawing, + editingRoiId, + handleToggleDraw, + handleSaveRoi, + handleDiscardRoi, + handleDeleteRoi, + handleDeleteAllRois, + handleToggleVisibility, + handleEditRoi, + handleUpdateRoi, + handleCancelEdit, + handleImportRois, + }; +} diff --git a/roi-selector/src/importRois.ts b/roi-selector/src/importRois.ts new file mode 100644 index 00000000..9440553d --- /dev/null +++ b/roi-selector/src/importRois.ts @@ -0,0 +1,319 @@ +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; +} + +// ---- 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}"`); + } + } + + 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.filter((t) => t.type === "roi_table" || t.type === "masking_roi_table"); + } 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 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/${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`); + } + + // 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/${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}"`); + 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. + */ +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 importedRois: SavedRoi[] = []; + let allRois = [...existingRois]; + + for (const tableName of selectedTables) { + try { + const physicalRois = await readRoiTable(tablesLocation, tableName); + + for (const pRoi of physicalRois) { + // 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 (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}), ` + + `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(x1, imageBounds.xMin, imageBounds.xMax), + y: clamp(y1, imageBounds.yMin, imageBounds.yMax), + }; + const corner2: RoiCorner = { + x: clamp(x2, imageBounds.xMin, imageBounds.xMax), + y: clamp(y2, imageBounds.yMin, imageBounds.yMax), + }; + + // Z axis (still index-based, no physical conversion) + 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); + corner2.z = clamp(z2, 0, zMax ?? z2); + if (zMax != null && (z1 > zMax || z2 > zMax)) { + console.warn( + `[ROI Import] "${tableName}/${pRoi.name}" Z range (${z1}–${z2}) exceeds zMax (${zMax}). Clamping.`, + ); + } + } + + // T axis (still index-based, no physical conversion) + 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); + corner2.t = clamp(t2, 0, tMax ?? t2); + if (tMax != null && (t1 > tMax || t2 > tMax)) { + console.warn( + `[ROI Import] "${tableName}/${pRoi.name}" T range (${t1}–${t2}) 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/index.tsx b/roi-selector/src/index.tsx new file mode 100644 index 00000000..80d48403 --- /dev/null +++ b/roi-selector/src/index.tsx @@ -0,0 +1,24 @@ +export { default as RoiSelector } from "./RoiSelector"; +export type { RoiSelectorProps } from "./RoiSelector"; + +export { useRoiDeckExtension } from "./useRoiDeckExtension"; +export type { UseRoiDeckExtensionProps, RoiDeckExtension } from "./useRoiDeckExtension"; + +// Re-export ROI state utilities for programmatic access +export { + ROI_COLORS, + normalizeRoiBounds, + boundsToPolygonXY, + toXY, + nextAvailableColor, + clampToBounds, +} 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 new file mode 100644 index 00000000..e40e8d22 --- /dev/null +++ b/roi-selector/src/state.ts @@ -0,0 +1,251 @@ +/** 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 } → first corner placed, waiting for second click + */ +export type RoiDrawState = null | "waiting-first" | { corner1: RoiCorner }; + +/** A saved ROI with its assigned overlay color. */ +export interface SavedRoi { + id: string; + name: string; + 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: RoiCorner; + corner2: RoiCorner; +} + +/** Normalized bounding box with guaranteed min/max ordering. */ +export interface NormalizedBounds { + 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 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`. + */ +export function normalizeRoiBounds(roi: { + corner1: RoiCorner; + corner2: RoiCorner; +}): NormalizedBounds { + 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(bounds: NormalizedBounds): Record { + const round2 = (v: number) => Math.round(v * 100) / 100; + const c: Record = { + x1: String(round2(bounds.min.x)), + y1: String(round2(bounds.min.y)), + x2: String(round2(bounds.max.x)), + y2: String(round2(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 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, + 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 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 extent of the loaded image in physical / world coordinates. */ +export interface ImageBounds { + xMin: number; + yMin: number; + xMax: number; + yMax: number; + /** Spatial unit from OME-Zarr metadata (e.g. "micrometer"). Empty string when unknown. */ + spatialUnit: string; +} + +/** + * 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, + 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, image.xMin, image.xMax), + y: clamp(bounds.min.y, image.yMin, image.yMax), + }; + const max: RoiCorner = { + x: clamp(bounds.max.x, image.xMin, image.xMax), + y: clamp(bounds.max.y, image.yMin, image.yMax), + }; + 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 && 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 && 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 && tMax != null) { + max.t = clamp(bounds.max.t, 0, tMax); + } else if (bounds.max.t !== undefined) { + max.t = bounds.max.t; + } + return { min, max }; +} + +/* 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 +]; + +/** + * 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. + */ +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]; +} + +/** 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; + 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 new file mode 100644 index 00000000..da41f0ed --- /dev/null +++ b/roi-selector/src/useRoiDeckExtension.ts @@ -0,0 +1,198 @@ +import type { Layer } from "deck.gl"; +import { PolygonLayer } from "deck.gl"; +import { useCallback, useMemo, useState } from "react"; + +import { + type ImageBounds, + type PendingRoi, + type RoiDrawState, + type SavedRoi, + boundsToPolygonXY, + nextAvailableColor, + normalizeRoiBounds, + toXY, +} from "./state"; + +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 click/hover handlers. + * + * 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({ + roiDrawState, + setRoiDrawState, + savedRois, + pendingRoi, + setPendingRoi, + imageBounds, + zInfo, + tInfo, +}: UseRoiDeckExtensionProps): RoiDeckExtension { + const isDrawing = roiDrawState !== null; + const currentZ = zInfo?.zValue ?? null; + const currentT = tInfo?.tValue ?? 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); + + // ---- 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) { + 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; + specs.push({ + id: `roi-saved-${roi.id}`, + polygon: boundsToPolygonXY(bounds), + fillColor: [...roi.color, 40], + lineColor: [...roi.color, 200], + }); + } + + // Pending ROI (drawn but not yet saved/discarded) + if (pendingRoi) { + specs.push({ + id: "roi-pending", + polygon: boundsToPolygonXY(normalizeRoiBounds(pendingRoi)), + fillColor: [...nextRoiColor, 60], + lineColor: [...nextRoiColor, 220], + }); + } + + // Preview rectangle (corner1 placed, following mouse) + if (roiCorner1 && roiMousePos) { + const [x1, y1] = toXY(roiCorner1); + const [x2, y2] = roiMousePos; + specs.push({ + id: "roi-preview", + polygon: [ + [x1, y1], + [x2, y1], + [x2, y2], + [x1, y2], + ], + fillColor: [...nextRoiColor, 40], + lineColor: [...nextRoiColor, 200], + }); + } + + 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) ---- + const onClick = useCallback( + (coordinate: [number, number]): boolean => { + if (!isDrawing) return false; + + const [rawX, rawY] = coordinate; + const round2 = (v: number) => Math.round(v * 100) / 100; + const clampV = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(v, hi)); + const x = round2(imageBounds ? clampV(rawX, imageBounds.xMin, imageBounds.xMax) : rawX); + const y = round2(imageBounds ? clampV(rawY, imageBounds.yMin, imageBounds.yMax) : rawY); + 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 }; + 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 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: corner, + }); + setRoiDrawState(null); + return true; + } + + return false; + }, + [isDrawing, roiDrawState, setRoiDrawState, setPendingRoi, zInfo, tInfo, imageBounds], + ); + + // ---- Hover handler (track mouse for preview rectangle) ---- + const onHover = useCallback( + (coordinate: [number, number] | null) => { + if (roiCorner1 && coordinate) { + setRoiMousePos(coordinate); + } else { + setRoiMousePos(null); + } + }, + [roiCorner1], + ); + + return { + layers: roiLayers, + cursor: isDrawing ? ("crosshair" as const) : undefined, + onClick, + onHover, + }; +} 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..e1359b50 100644 --- a/sites/app/src/App.tsx +++ b/sites/app/src/App.tsx @@ -1,3 +1,5 @@ +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"; @@ -20,12 +22,21 @@ function parseViewStateFromUrl(): ViewState | undefined { export default function App() { const urlString = window.location.href; - const { sources, viewState } = React.useMemo(() => { + React.useEffect(() => { + const url = new URL(window.location.href); + if (!url.searchParams.has("roi")) { + url.searchParams.set("roi", "0"); + window.history.replaceState(window.history.state, "", url.href); + } + }, []); + + const { sources, viewState, enableRoi } = React.useMemo(() => { const url = new URL(urlString); const { searchParams } = url; return { sources: searchParams.getAll("source"), viewState: parseViewStateFromUrl(), + enableRoi: searchParams.get("roi") === "1", }; }, [urlString]); @@ -41,14 +52,55 @@ 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), [], ); + // ---- 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 && viewerInfo && ( + + )} +
); } diff --git a/sites/app/vite.config.js b/sites/app/vite.config.js index 8cdbb9ad..d29a5c83 100644 --- a/sites/app/vite.config.js +++ b/sites/app/vite.config.js @@ -3,16 +3,18 @@ import * as path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; -export default defineConfig(({ mode }) => ({ - plugins: [react()], - base: "./", - resolve: { - alias: { - ...(mode === "development" - ? { - "@biongff/vizarr": path.resolve(__dirname, "../../viewer/src/index.tsx"), - } - : {}), +const source = process.env.VIZARR_DATA || "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/6001253.zarr"; + +export default defineConfig(({ mode }) => { + return { + base: "./", + plugins: [react()], + resolve: { + alias: { + "@biongff/vizarr": path.resolve(__dirname, "../../viewer/src/index.tsx"), + "@biongff/roi-selector": path.resolve(__dirname, "../../roi-selector/src/index.tsx"), + }, }, - }, -})); + server: { open: `?source=${source}` }, + }; +}); diff --git a/viewer/src/components/Viewer.tsx b/viewer/src/components/Viewer.tsx index b39b3b24..e30997ed 100644 --- a/viewer/src/components/Viewer.tsx +++ b/viewer/src/components/Viewer.tsx @@ -1,6 +1,6 @@ import { ScaleBarLayer } from "@hms-dbmi/viv"; import DeckGL from "deck.gl"; -import { OrthographicView } from "deck.gl"; +import { type Layer, OrthographicView } from "deck.gl"; import { useAtom, useAtomValue } from "jotai"; import * as React from "react"; import { useViewState } from "../hooks"; @@ -12,13 +12,21 @@ 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(); const layers = useAtomValue(layerAtoms); const firstLayer = layers[0] as VizarrLayer; - const axisNavigationSnackbar = useAxisNavigation(deckRef, viewport); + + const axisNavigationSnackbar = useAxisNavigation(deckRef); const resetViewState = React.useCallback( (layer: VizarrLayer) => { @@ -41,7 +49,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) { @@ -135,11 +144,35 @@ export default function Viewer() { }; }, [layers]); + // ---- 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]]; + onPluginClick?.(coord); + }, + [onPluginClick], + ); + + // ---- 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; + onPluginHover?.(coord); + }, + [onPluginHover], + ); + + // ---- Cursor ---- + const getCursor = React.useCallback(() => { + return pluginCursor ?? "grab"; + }, [pluginCursor]); + return ( <> @@ -149,7 +182,14 @@ export default function Viewer() { views={[new OrthographicView({ id: "ortho", controller: true, near, far })]} glOptions={glOptions} getTooltip={getTooltip} - onDeviceInitialized={() => setViewport(deckRef.current?.deck || null)} + onClick={handleClick} + onHover={handleHover} + getCursor={getCursor} + onDeviceInitialized={() => { + 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} diff --git a/viewer/src/components/VizarrViewer.tsx b/viewer/src/components/VizarrViewer.tsx index 19743551..48cceb8f 100644 --- a/viewer/src/components/VizarrViewer.tsx +++ b/viewer/src/components/VizarrViewer.tsx @@ -1,32 +1,136 @@ 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 { ViewStateContext, useViewState } from "../hooks"; import { createSourceData } from "../io"; import { type ImageLayerConfig, type ViewState, + type ViewportSize, + currentImageBoundsAtom, + currentTInfoAtom, + currentZInfoAtom, redirectObjAtom, + setTSliceAtom, + setZSliceAtom, sourceErrorAtom, sourceInfoAtom, sourceWarningAtom, viewStateAtom, + viewportAtom, } from "../state"; import theme from "../theme"; 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 { + 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; + 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, notifies the host of viewer state changes, + * and renders + + children. + */ +function ViewerBridge({ + sourceUrls, + onViewStateChange, + onViewerStateChange, + additionalLayers = [], + pluginCursor, + onPluginClick, + onPluginHover, + children, +}: { + sourceUrls: string[]; + 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; +}) { + const imageBounds = useAtomValue(currentImageBoundsAtom); + const zInfo = useAtomValue(currentZInfoAtom); + const tInfo = useAtomValue(currentTInfoAtom); + const viewport = useAtomValue(viewportAtom); + const [, setViewState] = useViewState(); + + const setZSlice = useSetAtom(setZSliceAtom); + const setTSlice = useSetAtom(setTSliceAtom); + + const stableSetViewState = React.useCallback( + (vs: ViewState) => { + setViewState(vs); + }, + [setViewState], + ); + + // Notify host application when viewer state changes + React.useEffect(() => { + onViewerStateChange?.({ + sourceUrl: sourceUrls[0] ?? "", + imageBounds, + zInfo, + tInfo, + viewport, + setViewState: stableSetViewState, + setZSlice, + setTSlice, + }); + }, [sourceUrls, imageBounds, zInfo, tInfo, viewport, stableSetViewState, setZSlice, setTSlice, onViewerStateChange]); + + return ( + <> + + + {children} + + ); } -function VizarrViewerComponent({ sources = [], viewState: initialViewState, onViewStateChange }: VizarrViewerProps) { +function VizarrViewerComponent({ + sources = [], + viewState: initialViewState, + onViewStateChange, + onViewerStateChange, + additionalLayers, + pluginCursor, + onPluginClick, + onPluginHover, + children, +}: VizarrViewerProps) { const setSourceInfo = useSetAtom(sourceInfoAtom); const setViewStateAtom = useSetAtom(viewStateAtom); const sourceError = useAtomValue(sourceErrorAtom); @@ -97,8 +201,17 @@ function VizarrViewerComponent({ sources = [], viewState: initialViewState, onVi <> {redirectObj === null && ( - - + + {children} + )} {sourceError !== null && ( @@ -155,11 +268,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/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 5fe6d55e..0b20015e 100644 --- a/viewer/src/index.tsx +++ b/viewer/src/index.tsx @@ -2,9 +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"; +export type { ViewState, ImageLayerConfig, ViewportSize } from "./state"; + +export { useViewState, ViewStateContext } from "./hooks"; diff --git a/viewer/src/state.ts b/viewer/src/state.ts index dab72dd2..4f7d0f46 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"; @@ -115,13 +121,133 @@ export const viewStateAtom = atom(null); export const sourceErrorAtom = atom(null); export const sourceWarningAtom = atom([]); +/** + * 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 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; + 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 extent of the first loaded source + * in **physical / world coordinates** (after applying the model matrix from + * OME-Zarr coordinateTransformations). This is the authoritative bound for + * ROI coordinates and matches the coordinate system used by deck.gl click + * events. + * + * 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 pixelW = loader.shape[xAxisIndex]; + const pixelH = loader.shape[yAxisIndex]; + const mat = source.model_matrix; + + // Transform the four pixel-space corners to world coordinates. + const corners = [ + [0, 0, 0], + [pixelW, 0, 0], + [pixelW, pixelH, 0], + [0, pixelH, 0], + ].map((c) => mat.transformAsPoint(c)); + + const unit = loader.meta?.physicalSizes?.x?.unit ?? ""; + + return { + xMin: Math.min(...corners.map((p) => p[0])), + yMin: Math.min(...corners.map((p) => p[1])), + xMax: Math.max(...corners.map((p) => p[0])), + yMax: Math.max(...corners.map((p) => p[1])), + spatialUnit: unit, + }; +}); + +/** + * 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 } }; + }); + } +}); + +/** + * 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; } export const redirectObjAtom = atom(null); -export const viewportAtom = atom(null); +export const viewportAtom = atom(null); export const sourceInfoAtom = atom[]>([]);