Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4279c36
feat!: roi selection plugin
LucaAnce Mar 18, 2026
dccc4e8
feat!: implemented multiple ROI selection and handling. UI cleaned an…
LucaAnce Mar 18, 2026
3744463
refactor: make roi-selector an optional plugin from pnpm
LucaAnce Mar 18, 2026
0dfaa89
refactor: moved roi-specific logic and atoms to roi folder
LucaAnce Mar 18, 2026
7c92c69
feat: bounds ROI (x,y,z) to image sizes
LucaAnce Mar 18, 2026
00ffac7
refactor: divided roi selector into components for ease of maintenance
LucaAnce Mar 18, 2026
08a985c
fix: correct go to roi bugs
LucaAnce Mar 18, 2026
25acb82
refactor: pnpm fix before PR
LucaAnce Mar 18, 2026
4e528af
refactor+fix: minor refactors/fixes from automatic AI PR review
LucaAnce Mar 18, 2026
13d8144
refactor: replace runtime fallback with compile-time toggle
LucaAnce Mar 18, 2026
22a171e
refactor(roi-selector): consolidate coordinate fields, reduce verbosi…
LucaAnce Mar 27, 2026
b87ca56
refactor: revert conditional roi-selector plugin logic
LucaAnce Mar 27, 2026
eb9b51f
refactor: consolidated corner data structure (vs previous x1,y1, ...)…
LucaAnce Apr 8, 2026
6edda3c
fix: solve bug of UI manual coord edit field able to save out of boun…
LucaAnce Apr 8, 2026
a0dea1e
feat: support roi names
LucaAnce Apr 8, 2026
2ff506a
refactor: replace shared atoms with ViewerPluginContext for plugin-vi…
LucaAnce Apr 8, 2026
927d94d
refactor: solve t/zMax info redundancy in API
LucaAnce Apr 9, 2026
c57a495
chore: remove redundant roi-selector.d.ts
LucaAnce Apr 9, 2026
cb6ecf8
fix: single roi exported as 1-item list to match fractal task require…
LucaAnce Apr 13, 2026
1216ca0
feat: delete all button. confirmation dialog before delete 1 or more …
LucaAnce Apr 14, 2026
5b28796
fix: change rel path and dev constraint for fractal integration
LucaAnce Apr 20, 2026
5693603
refactor: remove square bracket around exported single ROI for better…
LucaAnce Apr 22, 2026
b7247bf
refactor!: lift viewer plugin context to app level. app as viewer/plu…
LucaAnce Apr 23, 2026
a2a9cde
fix: make saved ROIs list scrollable and bounded to viewport size
LucaAnce Apr 27, 2026
926552c
refactor: uniform use of physical world coordinates everywhere
LucaAnce Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
"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",
Comment thread
LucaAnce marked this conversation as resolved.
"build:app": "pnpm --filter app build",
"build": "pnpm build:viewer && pnpm build:app"
"build": "pnpm build:viewer && pnpm build:roi-selector && pnpm build:app"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
Expand Down
47 changes: 47 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
packages:
- 'viewer'
- 'roi-selector'
- 'sites/*'
41 changes: 41 additions & 0 deletions roi-selector/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"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": {},
"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"
}
}
233 changes: 233 additions & 0 deletions roi-selector/src/RoiSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { CropFree } from "@mui/icons-material";
import { Box, Collapse, IconButton, Snackbar, Tooltip, Typography } from "@mui/material";
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 ImageBounds,
type PendingRoi,
type RoiDrawState,
type SavedRoi,
type ViewerInfo,
normalizeRoiBounds,
} from "./state";

export interface RoiSelectorProps {
roiDrawState: RoiDrawState;
setRoiDrawState: React.Dispatch<React.SetStateAction<RoiDrawState>>;
savedRois: SavedRoi[];
setSavedRois: React.Dispatch<React.SetStateAction<SavedRoi[]>>;
pendingRoi: PendingRoi | null;
setPendingRoi: React.Dispatch<React.SetStateAction<PendingRoi | null>>;
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,
} = 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);

/** 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<string, string | number> => {
const bounds = normalizeRoiBounds(roi);
const payload: Record<string, string | number> = {
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));
};

// ---- Render ----
return (
<Box
sx={{
zIndex: 1,
position: "absolute",
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: "5px",
right: "5px",
top: "5px",
padding: "4px 8px",
minWidth: 210,
maxHeight: "calc(100vh - 20px)",
display: "flex",
flexDirection: "column",
}}
>
<Tooltip title="Select Region of Interest">
<IconButton size="small" onClick={() => setOpen((prev) => !prev)} sx={{ color: "#fff" }}>
<CropFree fontSize="small" />
<Typography variant="caption" sx={{ ml: 0.5, color: "#fff" }}>
ROI Selection
</Typography>
</IconButton>
</Tooltip>

<Collapse in={open} sx={{ overflow: "hidden", display: "flex", flexDirection: "column", minHeight: 0 }}>
<Box sx={{ mt: 1, overflowY: "auto", minHeight: 0 }}>
{(pendingRoi || editingRoiId) && (
<RoiCoordinateFields
coords={coords}
onCoordChange={onCoordChange}
roiName={roiName}
onRoiNameChange={onRoiNameChange}
hasZAxis={hasZAxis}
hasTAxis={hasTAxis}
zInfo={zInfo}
tInfo={tInfo}
imageBounds={imageBounds}
/>
)}

<RoiDrawControls
editingRoiId={editingRoiId}
pendingRoi={pendingRoi}
isDrawing={isDrawing}
roiDrawState={roiDrawState}
onToggleDraw={handleToggleDraw}
onSave={handleSaveRoi}
onDiscard={handleDiscardRoi}
onUpdate={handleUpdateRoi}
onCancelEdit={handleCancelEdit}
/>

<SavedRoiList
savedRois={savedRois}
hasZAxis={hasZAxis}
hasTAxis={hasTAxis}
editingRoiId={editingRoiId}
roiMenuOpen={roiMenuOpen}
onToggleOpen={() => setRoiMenuOpen((prev) => !prev)}
onToggleVisibility={handleToggleVisibility}
onGoTo={handleGoToSavedRoi}
onCopy={handleCopySingleRoi}
onEdit={handleEditRoi}
onDelete={handleDeleteRoi}
onCopyAll={handleCopyAllRois}
onDeleteAll={handleDeleteAllRois}
/>
</Box>
</Collapse>

<Snackbar
open={snackOpen}
autoHideDuration={2000}
onClose={() => setSnackOpen(false)}
message="ROI coordinates copied!"
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
/>
</Box>
);
}

export default RoiSelector;
Loading