diff --git a/client/package-lock.json b/client/package-lock.json
index 7d21b89..673bc0a 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -11,8 +11,7 @@
"@czi-sds/components": "^22.11.4",
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
- "@idetik/core": "^0.7.5",
- "@idetik/react": "^0.2.5",
+ "@idetik/core": "0.32.2",
"@mui/icons-material": "5.16.7",
"@mui/material": "5.16.7",
"@tanstack/react-query": "^5.60.0",
@@ -313,56 +312,6 @@
"node": ">=6.9.0"
}
},
- "node_modules/@base-ui-components/react": {
- "version": "1.0.0-rc.0",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.28.4",
- "@base-ui-components/utils": "0.2.2",
- "@floating-ui/react-dom": "^2.1.6",
- "@floating-ui/utils": "^0.2.10",
- "reselect": "^5.1.1",
- "tabbable": "^6.3.0",
- "use-sync-external-store": "^1.6.0"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mui-org"
- },
- "peerDependencies": {
- "@types/react": "^17 || ^18 || ^19",
- "react": "^17 || ^18 || ^19",
- "react-dom": "^17 || ^18 || ^19"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
- "node_modules/@base-ui-components/utils": {
- "version": "0.2.2",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.28.4",
- "@floating-ui/utils": "^0.2.10",
- "reselect": "^5.1.1",
- "use-sync-external-store": "^1.6.0"
- },
- "peerDependencies": {
- "@types/react": "^17 || ^18 || ^19",
- "react": "^17 || ^18 || ^19",
- "react-dom": "^17 || ^18 || ^19"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@czi-sds/components": {
"version": "22.17.1",
"license": "MIT",
@@ -409,17 +358,6 @@
"stylis": "4.2.0"
}
},
- "node_modules/@emotion/css": {
- "version": "11.13.5",
- "license": "MIT",
- "dependencies": {
- "@emotion/babel-plugin": "^11.13.5",
- "@emotion/cache": "^11.13.5",
- "@emotion/serialize": "^1.3.3",
- "@emotion/sheet": "^1.4.0",
- "@emotion/utils": "^1.4.2"
- }
- },
"node_modules/@emotion/hash": {
"version": "0.9.2",
"license": "MIT"
@@ -1084,6 +1022,7 @@
},
"node_modules/@eslint/js": {
"version": "9.39.2",
+ "dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1112,36 +1051,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
- "node_modules/@floating-ui/core": {
- "version": "1.7.4",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/utils": "^0.2.10"
- }
- },
- "node_modules/@floating-ui/dom": {
- "version": "1.7.5",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/core": "^1.7.4",
- "@floating-ui/utils": "^0.2.10"
- }
- },
- "node_modules/@floating-ui/react-dom": {
- "version": "2.1.7",
- "license": "MIT",
- "dependencies": {
- "@floating-ui/dom": "^1.7.5"
- },
- "peerDependencies": {
- "react": ">=16.8.0",
- "react-dom": ">=16.8.0"
- }
- },
- "node_modules/@floating-ui/utils": {
- "version": "0.2.10",
- "license": "MIT"
- },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"dev": true,
@@ -1187,39 +1096,18 @@
}
},
"node_modules/@idetik/core": {
- "version": "0.7.7",
- "license": "MIT",
+ "version": "0.32.2",
+ "resolved": "https://registry.npmjs.org/@idetik/core/-/core-0.32.2.tgz",
+ "integrity": "sha512-e1Gli1kx25u0QSWXgNWM/n98fMk+uqMO9g5J/WnRdZDBUB51oo7Ty/FsIbiM5nuTmbR9SatoXRogHdUh3Y5yvg==",
"dependencies": {
"gl-matrix": "^3.4.3",
- "zarrita": "^0.5.4",
+ "webgpu-utils": "^2.1.0",
+ "zarrita": "^0.7.1",
"zod": "^3.24.1"
- }
- },
- "node_modules/@idetik/react": {
- "version": "0.2.5",
- "license": "MIT",
- "dependencies": {
- "@base-ui-components/react": "^1.0.0-beta.2",
- "@emotion/css": "^11.13.0",
- "@emotion/react": "^11.13.3",
- "@emotion/styled": "^11.13.0",
- "@eslint/js": "^9.9.0",
- "@mui/icons-material": "5.16.7",
- "@mui/lab": "5.0.0-alpha.173",
- "@mui/material": "5.16.7",
- "@types/react-dom": "^18.3.0",
- "react": "^18.3.1",
- "react-dom": "^18.3.1"
},
"engines": {
"node": ">=20.19.0",
"npm": ">=11.3.0"
- },
- "peerDependencies": {
- "@czi-sds/components": "^22.11.4",
- "@idetik/core": "^0.7.5",
- "react": "^18.3.1",
- "react-dom": "^18.3.1"
}
},
"node_modules/@jridgewell/gen-mapping": {
@@ -1258,36 +1146,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@mui/base": {
- "version": "5.0.0-beta.40",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.23.9",
- "@floating-ui/react-dom": "^2.0.8",
- "@mui/types": "^7.2.14",
- "@mui/utils": "^5.15.14",
- "@popperjs/core": "^2.11.8",
- "clsx": "^2.1.0",
- "prop-types": "^15.8.1"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mui-org"
- },
- "peerDependencies": {
- "@types/react": "^17.0.0 || ^18.0.0",
- "react": "^17.0.0 || ^18.0.0",
- "react-dom": "^17.0.0 || ^18.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@mui/core-downloads-tracker": {
"version": "5.18.0",
"license": "MIT",
@@ -1320,45 +1178,6 @@
}
}
},
- "node_modules/@mui/lab": {
- "version": "5.0.0-alpha.173",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.23.9",
- "@mui/base": "5.0.0-beta.40",
- "@mui/system": "^5.16.5",
- "@mui/types": "^7.2.15",
- "@mui/utils": "^5.16.5",
- "clsx": "^2.1.0",
- "prop-types": "^15.8.1"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mui-org"
- },
- "peerDependencies": {
- "@emotion/react": "^11.5.0",
- "@emotion/styled": "^11.3.0",
- "@mui/material": ">=5.15.0",
- "@types/react": "^17.0.0 || ^18.0.0",
- "react": "^17.0.0 || ^18.0.0",
- "react-dom": "^17.0.0 || ^18.0.0"
- },
- "peerDependenciesMeta": {
- "@emotion/react": {
- "optional": true
- },
- "@emotion/styled": {
- "optional": true
- },
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@mui/material": {
"version": "5.16.7",
"license": "MIT",
@@ -1704,9 +1523,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1721,9 +1537,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1736,9 +1549,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1751,9 +1561,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1768,9 +1575,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1785,9 +1589,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1802,9 +1603,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1819,9 +1617,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1836,9 +1631,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1853,9 +1645,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1870,9 +1659,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1887,9 +1673,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1904,9 +1687,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2093,6 +1873,7 @@
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -2336,11 +2117,12 @@
}
},
"node_modules/@zarrita/storage": {
- "version": "0.1.4",
- "license": "MIT",
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz",
+ "integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==",
"dependencies": {
"reference-spec-reader": "^0.2.0",
- "unzipit": "1.4.3"
+ "unzipit": "2.0.0"
}
},
"node_modules/acorn": {
@@ -3986,11 +3768,8 @@
},
"node_modules/reference-spec-reader": {
"version": "0.2.0",
- "license": "MIT"
- },
- "node_modules/reselect": {
- "version": "5.1.1",
- "license": "MIT"
+ "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
+ "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ=="
},
"node_modules/resolve": {
"version": "1.22.11",
@@ -4200,10 +3979,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/tabbable": {
- "version": "6.4.0",
- "license": "MIT"
- },
"node_modules/tailwindcss": {
"version": "3.4.19",
"dev": true,
@@ -4341,13 +4116,11 @@
"license": "MIT"
},
"node_modules/unzipit": {
- "version": "1.4.3",
- "license": "MIT",
- "dependencies": {
- "uzip-module": "^1.0.2"
- },
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz",
+ "integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==",
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/update-browserslist-db": {
@@ -4387,22 +4160,11 @@
"punycode": "^2.1.0"
}
},
- "node_modules/use-sync-external-store": {
- "version": "1.6.0",
- "license": "MIT",
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
"node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
- "node_modules/uzip-module": {
- "version": "1.0.3",
- "license": "MIT"
- },
"node_modules/vite": {
"version": "6.4.1",
"dev": true,
@@ -4487,6 +4249,11 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/webgpu-utils": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/webgpu-utils/-/webgpu-utils-2.1.1.tgz",
+ "integrity": "sha512-/v5I3DHwDdmznmPdFQsFfjOMSYTTvrtoflKyPjOMeXo+ND4l3BfvdLmJcI3+TjzM/54cxO174NKEkJ/5549xyQ=="
+ },
"node_modules/which": {
"version": "2.0.2",
"dev": true,
@@ -4533,10 +4300,11 @@
}
},
"node_modules/zarrita": {
- "version": "0.5.4",
- "license": "MIT",
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz",
+ "integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==",
"dependencies": {
- "@zarrita/storage": "^0.1.3",
+ "@zarrita/storage": "^0.2.0",
"numcodecs": "^0.3.2"
}
},
diff --git a/client/package.json b/client/package.json
index a9fb756..d962a79 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,8 +15,7 @@
"@czi-sds/components": "^22.11.4",
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
- "@idetik/core": "^0.7.5",
- "@idetik/react": "^0.2.5",
+ "@idetik/core": "0.32.2",
"@mui/icons-material": "5.16.7",
"@mui/material": "5.16.7",
"@tanstack/react-query": "^5.60.0",
diff --git a/client/src/components/overlays/InteractivePicksOverlay.tsx b/client/src/components/overlays/InteractivePicksOverlay.tsx
index 2914a4f..8b4a2e1 100644
--- a/client/src/components/overlays/InteractivePicksOverlay.tsx
+++ b/client/src/components/overlays/InteractivePicksOverlay.tsx
@@ -1,37 +1,34 @@
-/**
- * Interactive picks overlay that supports both viewing and editing modes.
- * Renders both read-only picks and editable picks being modified.
- *
- * When editing, the picks being edited are rendered from local state
- * instead of fetched data, allowing real-time visual feedback.
- */
-
-import { useEffect, useRef } from "react";
-import { useIdetik } from "@idetik/react";
+import { useMemo } from "react";
+import { Idetik } from "@/idetik/Idetik";
+import { PointsLayer } from "@/idetik/components/PointsLayer";
import { useCopick } from "@/contexts/CopickContext";
import { usePicking, type PickingPoint } from "@/contexts/PickingContext";
import { usePickPoints } from "@/api/hooks";
-import { PicksLayer } from "./layers/PicksLayer";
const DEFAULT_POINT_SIZE_PIXELS = 30;
-const DEFAULT_Z_FADE_FACTOR = 64.0;
-const SELECTED_POINT_SIZE_PIXELS = 40; // Larger for selected points
+const SELECTED_POINT_SIZE_PIXELS = 40; // larger for selected points
+
+type Rgba = [number, number, number, number];
interface InteractivePicksOverlayProps {
+ viewer: Idetik | null;
currentZIndex: number;
voxelSpacing: number;
}
-export function InteractivePicksOverlay({ currentZIndex, voxelSpacing }: InteractivePicksOverlayProps) {
+export function InteractivePicksOverlay({ viewer, currentZIndex, voxelSpacing }: InteractivePicksOverlayProps) {
const { state: copickState } = useCopick();
const { state: pickingState, isEditing } = usePicking();
- // Get all visible picks
+ const worldZ = currentZIndex * voxelSpacing;
const visiblePicks = copickState.selectedPicks.filter((p) => p.visible);
+ if (!viewer) {
+ return null;
+ }
+
return (
<>
- {/* Read-only picks (excluding the one being edited) */}
{visiblePicks
.filter(
(pick) =>
@@ -41,182 +38,73 @@ export function InteractivePicksOverlay({ currentZIndex, voxelSpacing }: Interac
pick.sessionId !== pickingState.editingPicks?.sessionId
)
.map((pick) => (
-
))}
- {/* Editable picks layer */}
{isEditing && pickingState.editingPicks && (
-
)}
>
);
}
-// Read-only layer (same as existing PickPointsLayer)
-interface ReadOnlyPickPointsLayerProps {
- objectName: string;
- userId: string;
- sessionId: string;
- currentZIndex: number;
- voxelSpacing: number;
-}
-
-function ReadOnlyPickPointsLayer({
+function ReadOnlyPicks({
+ viewer,
objectName,
userId,
sessionId,
- currentZIndex,
- voxelSpacing,
-}: ReadOnlyPickPointsLayerProps) {
+ worldZ,
+}: {
+ viewer: Idetik;
+ objectName: string;
+ userId: string;
+ sessionId: string;
+ worldZ: number;
+}) {
const { state } = useCopick();
- const { runtime } = useIdetik();
- const layerRef = useRef(null);
-
const { data: picks } = usePickPoints(state.selectedRunName, objectName, userId, sessionId);
-
- useEffect(() => {
- if (!runtime || !picks || picks.points.length === 0) {
- return;
- }
-
- const layerManager = runtime.viewports[0]?.layerManager;
- if (!layerManager) return;
-
- if (layerRef.current) {
- layerManager.remove(layerRef.current);
- layerRef.current = null;
- }
-
- const layer = new PicksLayer({
- points: picks.points.map((pt) => ({ x: pt.x, y: pt.y, z: pt.z })),
- color: picks.color,
- pointSizePixels: DEFAULT_POINT_SIZE_PIXELS,
- zFadeRadius: DEFAULT_Z_FADE_FACTOR,
- });
-
- const currentZInAngstroms = currentZIndex * voxelSpacing;
- layer.setCurrentZ(currentZInAngstroms);
-
- layerManager.add(layer);
- layerRef.current = layer;
-
- return () => {
- if (layerRef.current && layerManager.layers.includes(layerRef.current)) {
- layerManager.remove(layerRef.current);
- layerRef.current = null;
- }
- };
- }, [runtime, picks]);
-
- useEffect(() => {
- if (layerRef.current) {
- const currentZInAngstroms = currentZIndex * voxelSpacing;
- layerRef.current.setCurrentZ(currentZInAngstroms);
- }
- }, [currentZIndex, voxelSpacing]);
-
- return null;
+ return (
+
+ );
}
-// Editable layer with selection highlighting
-interface EditablePicksLayerProps {
+function EditablePicks({
+ viewer,
+ points,
+ selectedIds,
+ color,
+ worldZ,
+}: {
+ viewer: Idetik;
points: PickingPoint[];
selectedIds: Set;
- color: [number, number, number, number];
- currentZIndex: number;
- voxelSpacing: number;
-}
-
-function EditablePicksLayer({ points, selectedIds, color, currentZIndex, voxelSpacing }: EditablePicksLayerProps) {
- const { runtime } = useIdetik();
- const normalLayerRef = useRef(null);
- const selectedLayerRef = useRef(null);
-
- useEffect(() => {
- if (!runtime) return;
-
- const layerManager = runtime.viewports[0]?.layerManager;
- if (!layerManager) return;
-
- // Clean up old layers
- if (normalLayerRef.current && layerManager.layers.includes(normalLayerRef.current)) {
- layerManager.remove(normalLayerRef.current);
- }
- if (selectedLayerRef.current && layerManager.layers.includes(selectedLayerRef.current)) {
- layerManager.remove(selectedLayerRef.current);
- }
- normalLayerRef.current = null;
- selectedLayerRef.current = null;
-
- const normalPoints = points.filter((p) => !selectedIds.has(p.id));
- const selectedPoints = points.filter((p) => selectedIds.has(p.id));
- const currentZInAngstroms = currentZIndex * voxelSpacing;
-
- // Normal points layer
- if (normalPoints.length > 0) {
- const normalLayer = new PicksLayer({
- points: normalPoints.map((p) => ({ x: p.x, y: p.y, z: p.z })),
- color,
- pointSizePixels: DEFAULT_POINT_SIZE_PIXELS,
- zFadeRadius: DEFAULT_Z_FADE_FACTOR,
- });
- normalLayer.setCurrentZ(currentZInAngstroms);
- layerManager.add(normalLayer);
- normalLayerRef.current = normalLayer;
- }
-
- // Selected points layer (brighter, larger)
- if (selectedPoints.length > 0) {
- // Use a brighter version of the color for selection
- const highlightColor: [number, number, number, number] = [
- Math.min(255, color[0] + 80),
- Math.min(255, color[1] + 80),
- Math.min(255, color[2] + 80),
- 255,
- ];
-
- const selectedLayer = new PicksLayer({
- points: selectedPoints.map((p) => ({ x: p.x, y: p.y, z: p.z })),
- color: highlightColor,
- pointSizePixels: SELECTED_POINT_SIZE_PIXELS,
- zFadeRadius: DEFAULT_Z_FADE_FACTOR,
- });
- selectedLayer.setCurrentZ(currentZInAngstroms);
- layerManager.add(selectedLayer);
- selectedLayerRef.current = selectedLayer;
- }
-
- return () => {
- if (normalLayerRef.current && layerManager.layers.includes(normalLayerRef.current)) {
- layerManager.remove(normalLayerRef.current);
- }
- if (selectedLayerRef.current && layerManager.layers.includes(selectedLayerRef.current)) {
- layerManager.remove(selectedLayerRef.current);
- }
- normalLayerRef.current = null;
- selectedLayerRef.current = null;
- };
- }, [runtime, points, selectedIds, color, currentZIndex, voxelSpacing]);
-
- // Update z-position when slice changes (without recreating layers)
- useEffect(() => {
- const currentZInAngstroms = currentZIndex * voxelSpacing;
- normalLayerRef.current?.setCurrentZ(currentZInAngstroms);
- selectedLayerRef.current?.setCurrentZ(currentZInAngstroms);
- }, [currentZIndex, voxelSpacing]);
+ color: Rgba;
+ worldZ: number;
+}) {
+ const normalPoints = useMemo(() => points.filter((p) => !selectedIds.has(p.id)), [points, selectedIds]);
+ const selectedPoints = useMemo(() => points.filter((p) => selectedIds.has(p.id)), [points, selectedIds]);
+ const highlightColor = useMemo(
+ () => [Math.min(255, color[0] + 80), Math.min(255, color[1] + 80), Math.min(255, color[2] + 80), 255],
+ [color]
+ );
- return null;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/client/src/components/overlays/PicksOverlay.tsx b/client/src/components/overlays/PicksOverlay.tsx
deleted file mode 100644
index 89beaf8..0000000
--- a/client/src/components/overlays/PicksOverlay.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * Picks overlay that renders particle locations using idetik's Points layer.
- *
- * Uses the PicksLayer class which integrates with idetik's WebGL rendering
- * for efficient rendering of large numbers of points with z-depth based
- * visibility and scaling.
- */
-
-import { useEffect, useRef } from "react";
-import { useIdetik } from "@idetik/react";
-import { useCopick } from "@/contexts/CopickContext";
-import { usePickPoints } from "@/api/hooks";
-import { PicksLayer } from "./layers/PicksLayer";
-
-// Default values that work well visually
-const DEFAULT_POINT_SIZE_PIXELS = 30; // Base size in screen pixels
-const DEFAULT_Z_FADE_FACTOR = 64.0; // Angstroms - controls how quickly points fade with z-distance
-
-interface PicksOverlayProps {
- currentZIndex: number;
-}
-
-export function PicksOverlay({ currentZIndex }: PicksOverlayProps) {
- const { state: copickState } = useCopick();
-
- // Get all visible picks
- const visiblePicks = copickState.selectedPicks.filter((p) => p.visible);
-
- if (visiblePicks.length === 0) {
- return null;
- }
-
- return (
- <>
- {visiblePicks.map((pick) => (
-
- ))}
- >
- );
-}
-
-interface PickPointsLayerProps {
- objectName: string;
- userId: string;
- sessionId: string;
- currentZIndex: number;
-}
-
-function PickPointsLayer({
- objectName,
- userId,
- sessionId,
- currentZIndex,
-}: PickPointsLayerProps) {
- const { state } = useCopick();
- const { runtime } = useIdetik();
- const layerRef = useRef(null);
-
- // Get voxel spacing to convert slice index to Angstroms
- const voxelSpacing = state.selectedVoxelSize ?? 20;
-
- // Fetch the pick points
- const { data: picks } = usePickPoints(state.selectedRunName, objectName, userId, sessionId);
-
- // Create/update layer when picks data changes
- useEffect(() => {
- if (!runtime || !picks || picks.points.length === 0) {
- return;
- }
-
- const layerManager = runtime.viewports[0]?.layerManager;
- if (!layerManager) {
- return;
- }
-
- // Remove old layer if it exists
- if (layerRef.current) {
- layerManager.remove(layerRef.current);
- layerRef.current = null;
- }
-
- // Create new layer with the points using default visual settings
- const layer = new PicksLayer({
- points: picks.points.map((pt) => ({ x: pt.x, y: pt.y, z: pt.z })),
- color: picks.color,
- pointSizePixels: DEFAULT_POINT_SIZE_PIXELS,
- zFadeRadius: DEFAULT_Z_FADE_FACTOR,
- });
-
- // Convert slice index to Angstroms (same units as point coordinates)
- const currentZInAngstroms = currentZIndex * voxelSpacing;
- layer.setCurrentZ(currentZInAngstroms);
-
- // Add to layer manager
- layerManager.add(layer);
- layerRef.current = layer;
-
- // Cleanup on unmount or data change
- return () => {
- if (layerRef.current && layerManager.layers.includes(layerRef.current)) {
- layerManager.remove(layerRef.current);
- layerRef.current = null;
- }
- };
- }, [runtime, picks]);
-
- // Update z-position when slice changes
- useEffect(() => {
- if (layerRef.current) {
- const currentZInAngstroms = currentZIndex * voxelSpacing;
- layerRef.current.setCurrentZ(currentZInAngstroms);
- }
- }, [currentZIndex, voxelSpacing]);
-
- return null;
-}
diff --git a/client/src/components/overlays/SegmentationOverlay.tsx b/client/src/components/overlays/SegmentationOverlay.tsx
index 452e644..5dc1c70 100644
--- a/client/src/components/overlays/SegmentationOverlay.tsx
+++ b/client/src/components/overlays/SegmentationOverlay.tsx
@@ -1,36 +1,29 @@
-/**
- * Segmentation overlay that renders segmentation masks using idetik's LabelImageLayer.
- *
- * Each visible segmentation is loaded as a separate layer with its own color mapping.
- * The layer is recreated when the z-index changes to show the correct slice.
- */
-
-import { useEffect, useRef, useState } from "react";
-import { useIdetik } from "@idetik/react";
-import { OmeZarrImageSource, LabelImageLayer, Region } from "@idetik/core";
+import { Idetik } from "@/idetik/Idetik";
+import { SegmentationLayer } from "@/idetik/components/SegmentationLayer";
import { useCopick } from "@/contexts/CopickContext";
import { useSegmentations, useObjects } from "@/api/hooks";
+type Rgba = [number, number, number, number];
+
+function toLabelColor([r, g, b, a]: Rgba): Rgba {
+ return [r / 255, g / 255, b / 255, (a / 255) * 0.5];
+}
+
interface SegmentationOverlayProps {
+ viewer: Idetik | null;
currentZIndex: number;
- voxelSpacing: number; // Reserved for future coordinate conversion
+ voxelSpacing: number;
}
-export function SegmentationOverlay({ currentZIndex, voxelSpacing }: SegmentationOverlayProps) {
+export function SegmentationOverlay({ viewer, currentZIndex, voxelSpacing }: SegmentationOverlayProps) {
const { state: copickState } = useCopick();
-
- // Get all visible segmentations
- const visibleSegmentations = copickState.selectedSegmentations.filter((s) => s.visible);
-
- // Get segmentation metadata from API
const { data: segmentations } = useSegmentations(copickState.selectedRunName);
-
- // Get objects from copick config for label→color mapping
const { data: objects } = useObjects();
- // Build label→color map from copick objects config
- // Each object has a `label` field (numeric ID used in multilabel segmentations)
- const labelColorMap = new Map();
+ const worldZ = currentZIndex * voxelSpacing;
+ const visibleSegmentations = copickState.selectedSegmentations.filter((s) => s.visible);
+
+ const labelColorMap = new Map();
if (objects) {
for (const obj of objects) {
if (obj.label !== null && obj.label !== 0) {
@@ -39,14 +32,13 @@ export function SegmentationOverlay({ currentZIndex, voxelSpacing }: Segmentatio
}
}
- if (visibleSegmentations.length === 0 || !segmentations) {
+ if (!viewer || visibleSegmentations.length === 0 || !segmentations) {
return null;
}
return (
<>
{visibleSegmentations.map((seg) => {
- // Find the full segmentation data from API
const segData = segmentations.find(
(s) =>
s.name === seg.name &&
@@ -54,214 +46,28 @@ export function SegmentationOverlay({ currentZIndex, voxelSpacing }: Segmentatio
s.session_id === seg.sessionId &&
s.voxel_size === seg.voxelSize
);
+ if (!segData?.zarr_url) return null;
- if (!segData?.zarr_url) {
- return null;
+ const lookupTable = new Map([[0, [0, 0, 0, 0]]]);
+ if (segData.is_multilabel && labelColorMap.size > 0) {
+ for (const [label, rgba] of labelColorMap) {
+ lookupTable.set(label, toLabelColor(rgba));
+ }
+ } else {
+ const color = objects?.find((obj) => obj.name === seg.name)?.color ?? segData.color ?? [255, 0, 0, 128];
+ lookupTable.set(1, toLabelColor(color));
}
- // For single-label segmentations, get color from the pickable object matching the segmentation name
- const objectForSeg = objects?.find((obj) => obj.name === seg.name);
- const singleLabelColor = objectForSeg?.color ?? segData.color ?? [255, 0, 0, 128];
-
return (
);
})}
>
);
}
-
-interface SegmentationLayerProps {
- zarrUrl: string;
- color: [number, number, number, number];
- isMultilabel: boolean;
- labelColorMap: Map;
- currentZIndex: number;
- tomogramVoxelSpacing: number;
- segmentationVoxelSize: number;
-}
-
-function SegmentationLayer({
- zarrUrl,
- color,
- isMultilabel,
- labelColorMap,
- currentZIndex,
- tomogramVoxelSpacing,
- segmentationVoxelSize,
-}: SegmentationLayerProps) {
- const { runtime } = useIdetik();
- const layerRef = useRef(null);
- const sourceRef = useRef(null);
- const [isSourceReady, setIsSourceReady] = useState(false);
-
- // Create source once when component mounts
- useEffect(() => {
- const fullUrl = `${window.location.origin}${zarrUrl}`;
- const source = OmeZarrImageSource.fromHttp({ url: fullUrl });
- sourceRef.current = source;
-
- // Open the source to ensure it's ready
- source.open().then(() => {
- console.log("[SegmentationOverlay] Source opened:", fullUrl);
- setIsSourceReady(true);
- }).catch((error: Error) => {
- console.error("Failed to open segmentation source:", error);
- });
-
- return () => {
- sourceRef.current = null;
- setIsSourceReady(false);
- };
- }, [zarrUrl]);
-
- // Create/update layer when z-index changes or source becomes ready
- useEffect(() => {
- console.log("[SegmentationOverlay] Effect triggered - currentZIndex:", currentZIndex);
-
- if (!runtime || !isSourceReady || !sourceRef.current) {
- console.log("[SegmentationOverlay] Early return - runtime:", !!runtime, "isSourceReady:", isSourceReady);
- return;
- }
-
- const layerManager = runtime.viewports[0]?.layerManager;
- if (!layerManager) {
- return;
- }
-
- // Remove old layer if it exists
- if (layerRef.current) {
- if (layerManager.layers.includes(layerRef.current)) {
- layerManager.remove(layerRef.current);
- }
- layerRef.current = null;
- }
-
- // Get source dimension info to check z bounds
- sourceRef.current.open().then(loader => {
- const dimMap = loader.getSourceDimensionMap();
- const zDim = dimMap.z;
- const maxZ = zDim ? zDim.lods[0].size - 1 : "unknown";
- console.log("[SegmentationOverlay] Z-dimension info:", {
- currentZIndex,
- tomogramVoxelSpacing,
- segmentationVoxelSize,
- maxZIndex: maxZ,
- zLodInfo: zDim?.lods[0],
- });
- });
-
- // Convert tomogram z-index to WORLD COORDINATES (Angstroms)
- // idetik's Region expects world coordinates, not array indices!
- // It internally converts: array_index = (worldZ - translation) / scale
- const worldZ = currentZIndex * tomogramVoxelSpacing;
- console.log("[SegmentationOverlay] Z-position:", {
- currentZIndex,
- tomogramVoxelSpacing,
- worldZ,
- });
-
- // Create region for specific z-slice using world coordinates
- const region: Region = [
- { dimension: "z", index: { type: "point", value: worldZ } },
- { dimension: "y", index: { type: "full" } },
- { dimension: "x", index: { type: "full" } },
- ];
- console.log("[SegmentationOverlay] Creating region with worldZ:", worldZ);
-
- // Create color map based on copick config or segmentation color
- // LabelColorMapProps uses lookupTable (Map) and cycle (array)
- const lookupTable = new Map();
- lookupTable.set(0, [0, 0, 0, 0]); // Background (label 0) is always transparent
-
- if (isMultilabel && labelColorMap.size > 0) {
- // For multilabel segmentations, use colors from copick config
- // Each object in config has a `label` field that maps to pixel values in the segmentation
- for (const [label, rgba] of labelColorMap) {
- const r = rgba[0] / 255;
- const g = rgba[1] / 255;
- const b = rgba[2] / 255;
- const a = (rgba[3] / 255) * 0.5; // 50% opacity for overlay
- lookupTable.set(label, [r, g, b, a]);
- }
- } else {
- // For single-label segmentations, use the provided color for label 1
- const r = color[0] / 255;
- const g = color[1] / 255;
- const b = color[2] / 255;
- const a = (color[3] / 255) * 0.5; // 50% opacity for overlay
- lookupTable.set(1, [r, g, b, a]);
- }
-
- // No color cycle needed - we explicitly map all labels from copick config
- const cycle = undefined;
-
- // Create the label layer with transparency enabled
- // Explicitly set lod: 0 to use highest resolution (segmentations usually have no multiscale)
- const layer = new LabelImageLayer({
- source: sourceRef.current,
- region,
- colorMap: { lookupTable, cycle },
- transparent: true,
- blendMode: "normal",
- lod: 0,
- });
-
- // Log transform info when layer is ready (for debugging alignment issues)
- layer.addStateChangeCallback((newState) => {
- if (newState === "ready") {
- const objects = layer.objects;
- if (objects.length > 0) {
- const renderable = objects[0];
- const transform = renderable.transform;
- console.log("[SegmentationOverlay] Layer READY - transform:", {
- scale: Array.from(transform.scale),
- translation: Array.from(transform.translation),
- });
- } else {
- console.log("[SegmentationOverlay] Layer READY but NO objects!");
- }
- }
- });
-
- // Add to layer manager
- layerManager.add(layer);
- layerRef.current = layer;
-
- // Debug: Log coordinate info, layer count, and color info
- const allLayers = layerManager.layers;
- console.log("[SegmentationOverlay] Layer created:", {
- zarrUrl,
- worldZ,
- tomogramVoxelSpacing,
- segmentationVoxelSize,
- isMultilabel,
- labelCount: lookupTable.size,
- labels: Array.from(lookupTable.keys()),
- // Show colors being used
- colorForLabel1: lookupTable.get(1),
- // Layer ordering info
- totalLayers: allLayers.length,
- layerTypes: allLayers.map(l => l.type),
- });
-
- // Cleanup when z-index changes or unmount
- return () => {
- if (layerRef.current && layerManager.layers.includes(layerRef.current)) {
- layerManager.remove(layerRef.current);
- layerRef.current = null;
- }
- };
- }, [runtime, isSourceReady, currentZIndex, color, isMultilabel, labelColorMap, tomogramVoxelSpacing, segmentationVoxelSize]);
-
- return null;
-}
diff --git a/client/src/components/overlays/layers/PicksLayer.ts b/client/src/components/overlays/layers/PicksLayer.ts
deleted file mode 100644
index 86e13c9..0000000
--- a/client/src/components/overlays/layers/PicksLayer.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * idetik Layer for rendering pick points.
- * Based on the Particles implementation from try-cryolens/app/common/ParticlesLayer.ts
- */
-
-import { Layer, Points, Texture2DArray, Color } from "@idetik/core";
-import { vec3 } from "gl-matrix";
-
-// Marker atlas size
-const SPRITE_SIZE = 256;
-
-type PointData = {
- position: vec3;
- color: Color;
- size: number;
- markerIndex: number;
-};
-
-export interface PicksLayerOptions {
- points: Array<{ x: number; y: number; z: number }>;
- color: [number, number, number, number]; // RGBA 0-255
- pointSizePixels: number; // Diameter in pixels (converted from Angstroms)
- zFadeRadius: number; // Radius in Angstroms for z-depth fading
-}
-
-export class PicksLayer extends Layer {
- public readonly type = "PicksLayer";
-
- private readonly rawPoints_: Array<{ x: number; y: number; z: number }>;
- private readonly color_: Color;
- private readonly pointSizePixels_: number;
- private readonly zFadeRadius_: number;
- private static markerAtlas_: Texture2DArray;
- private needsUpdate_ = true;
- private currentZ_ = 0;
-
- constructor(options: PicksLayerOptions) {
- super();
- this.setState("initialized");
- this.rawPoints_ = options.points;
- // Use Color.from() for robust color parsing (like cryolens)
- this.color_ = Color.from([
- options.color[0] / 255,
- options.color[1] / 255,
- options.color[2] / 255,
- options.color[3] / 255,
- ]);
- this.pointSizePixels_ = options.pointSizePixels;
- this.zFadeRadius_ = options.zFadeRadius;
- // Enable transparency for proper blending (like cryolens)
- this.transparent = true;
- this.refreshPointsRenderable();
- this.setState("ready");
- }
-
- /**
- * Set the current Z position (in world coordinates).
- * Points will be scaled/dimmed based on distance from this position.
- */
- public setCurrentZ(z: number) {
- if (this.currentZ_ !== z) {
- this.currentZ_ = z;
- this.needsUpdate_ = true;
- }
- }
-
- public update() {
- if (!this.needsUpdate_) {
- return;
- }
- this.refreshPointsRenderable();
- this.needsUpdate_ = false;
- }
-
- private refreshPointsRenderable() {
- const { r, g, b } = this.color_;
-
- // Filter and scale points by depth
- const scaledPoints: PointData[] = [];
-
- for (const pt of this.rawPoints_) {
- // Calculate distance from current z-slice
- const zDist = Math.abs(pt.z - this.currentZ_);
-
- // Z-fading based on radius: points fade as they move away from current slice
- // At zDist = 0: zScale = 1 (full size/opacity)
- // At zDist = zFadeRadius: zScale = 2 (half size/opacity)
- // At zDist = 2*zFadeRadius: zScale = 3 (third size/opacity)
- const zScale = zDist / this.zFadeRadius_ + 1.0;
-
- // Scale size by devicePixelRatio (critical for high-DPI displays)
- const size = (this.pointSizePixels_ * (window.devicePixelRatio || 1)) / zScale;
-
- // Skip very small points
- if (size < 0.1) {
- continue;
- }
-
- // Calculate opacity based on z-distance
- const opacity = 1.0 / zScale;
-
- scaledPoints.push({
- // Put all points at z=10 to render in front of image plane
- // The actual pt.z is only used for depth-based size/opacity calculation
- position: vec3.fromValues(pt.x, pt.y, 10),
- color: Color.from([r, g, b, opacity]),
- size,
- markerIndex: 0, // Circle marker
- });
- }
-
- // Clear existing objects
- this.objects.length = 0;
-
- if (scaledPoints.length > 0) {
- const pointsRenderable = new Points(scaledPoints, PicksLayer.markerAtlas);
- this.addObject(pointsRenderable);
- }
- }
-
- private static get markerAtlas() {
- if (!PicksLayer.markerAtlas_) {
- PicksLayer.markerAtlas_ = PicksLayer.createMarkerAtlas();
- }
- return PicksLayer.markerAtlas_;
- }
-
- private static createMarkerAtlas() {
- // Create a simple circle marker
- const circle = (size: number) => {
- const data = new Float32Array(size * size);
- const center = size / 2;
- const radius = size / 2;
-
- for (let y = 0; y < size; y++) {
- for (let x = 0; x < size; x++) {
- const dx = x - center;
- const dy = y - center;
- const dist = Math.sqrt(dx * dx + dy * dy);
-
- // Soft edge for anti-aliasing
- if (dist < radius - 1) {
- data[y * size + x] = 1.0;
- } else if (dist < radius) {
- data[y * size + x] = radius - dist;
- }
- }
- }
- return data;
- };
-
- // Create atlas with just circle marker for now
- const circleData = circle(SPRITE_SIZE);
-
- const texture = new Texture2DArray(circleData, SPRITE_SIZE, SPRITE_SIZE);
- texture.wrapR = "clamp_to_edge";
- texture.wrapS = "clamp_to_edge";
- texture.wrapT = "clamp_to_edge";
- return texture;
- }
-
- /**
- * Get the number of visible points at current z-position
- * (points whose rendered size would be >= 0.1 pixels)
- */
- public get visiblePointCount(): number {
- return this.rawPoints_.filter((pt) => {
- const zDist = Math.abs(pt.z - this.currentZ_);
- const zScale = zDist / this.zFadeRadius_ + 1.0;
- const size = (this.pointSizePixels_ * (window.devicePixelRatio || 1)) / zScale;
- return size >= 0.1;
- }).length;
- }
-
- /**
- * Get total number of points
- */
- public get totalPointCount(): number {
- return this.rawPoints_.length;
- }
-}
diff --git a/client/src/components/picking/PickingEventHandler.tsx b/client/src/components/picking/PickingEventHandler.tsx
index 38a4b4b..0d5cc0c 100644
--- a/client/src/components/picking/PickingEventHandler.tsx
+++ b/client/src/components/picking/PickingEventHandler.tsx
@@ -1,14 +1,9 @@
-/**
- * Handles mouse events for picking interactions.
- * Attaches event listeners to the viewer canvas.
- */
-
-import { useEffect, useCallback, useRef } from "react";
-import { OrthographicCamera } from "@idetik/core";
-import { useIdetik } from "@idetik/react";
+import { useCallback, useEffect } from "react";
+import { Idetik } from "@/idetik/Idetik";
import { usePicking, type PickingPoint } from "@/contexts/PickingContext";
interface PickingEventHandlerProps {
+ viewer: Idetik | null;
currentZIndex: number;
onZIndexChange: (newIndex: number) => void;
maxZIndex: number | undefined;
@@ -16,74 +11,16 @@ interface PickingEventHandlerProps {
}
export function PickingEventHandler({
+ viewer,
currentZIndex,
onZIndexChange,
maxZIndex,
voxelSpacing,
}: PickingEventHandlerProps) {
- const { runtime } = useIdetik();
const { state: pickingState, addPoint, deletePoint, selectPoint, clearSelection, isEditing } = usePicking();
- // Store canvas reference
- const canvasRef = useRef(null);
-
- // Get canvas when runtime is available
- useEffect(() => {
- if (!runtime) return;
- // Access canvas directly from runtime
- canvasRef.current = runtime.canvas;
- }, [runtime]);
-
- // Convert screen coordinates to world coordinates
- const screenToWorld = useCallback(
- (screenX: number, screenY: number): { x: number; y: number } | null => {
- if (!runtime || !canvasRef.current) return null;
-
- const viewport = runtime.viewports[0];
- if (!viewport) return null;
-
- // Get canvas bounds
- const canvas = runtime.canvas;
- const rect = canvas.getBoundingClientRect();
- const canvasX = screenX - rect.left;
- const canvasY = screenY - rect.top;
-
- // Get camera for coordinate conversion
- const camera = viewport.camera;
- if (!camera || camera.type !== "OrthographicCamera") return null;
-
- const orthoCamera = camera as OrthographicCamera;
-
- // Canvas dimensions
- const canvasWidth = canvas.clientWidth;
- const canvasHeight = canvas.clientHeight;
-
- // Convert from canvas space to normalized device coordinates (-1 to 1)
- // Note: Don't flip Y - image coordinates have Y increasing downward
- const ndcX = (canvasX / canvasWidth) * 2 - 1;
- const ndcY = (canvasY / canvasHeight) * 2 - 1;
-
- // For idetik's orthographic camera:
- // cameraWidthWorld = transform.scale[0] * viewportSize[0]
- const cameraWidthWorld = orthoCamera.transform.scale[0] * orthoCamera.viewportSize[0];
- const cameraHeightWorld = orthoCamera.transform.scale[1] * orthoCamera.viewportSize[1];
-
- // Camera center is at transform.translation
- const centerX = orthoCamera.transform.translation[0];
- const centerY = orthoCamera.transform.translation[1];
-
- // Convert NDC to world coordinates
- const worldX = centerX + ndcX * (cameraWidthWorld / 2);
- const worldY = centerY + ndcY * (cameraHeightWorld / 2);
-
- return { x: worldX, y: worldY };
- },
- [runtime]
- );
-
- // Find nearest point to click location
const findNearestPoint = useCallback(
- (worldX: number, worldY: number, threshold: number = 50): PickingPoint | null => {
+ (worldX: number, worldY: number, threshold = 50): PickingPoint | null => {
if (!isEditing) return null;
const currentZ = currentZIndex * voxelSpacing;
@@ -91,12 +28,8 @@ export function PickingEventHandler({
let minDist = threshold;
for (const point of pickingState.localPoints) {
- // Only consider points near current z-slice (within 3 slices)
- const zDist = Math.abs(point.z - currentZ);
- if (zDist > voxelSpacing * 3) continue;
-
- const dist = Math.sqrt(Math.pow(point.x - worldX, 2) + Math.pow(point.y - worldY, 2));
-
+ if (Math.abs(point.z - currentZ) > voxelSpacing * 3) continue;
+ const dist = Math.hypot(point.x - worldX, point.y - worldY);
if (dist < minDist) {
minDist = dist;
nearest = point;
@@ -108,56 +41,41 @@ export function PickingEventHandler({
[isEditing, pickingState.localPoints, currentZIndex, voxelSpacing]
);
- // Handle mouse click
const handleClick = useCallback(
(event: MouseEvent) => {
- if (!isEditing) return;
-
- // Don't intercept if user is interacting with UI elements
+ if (!viewer || !isEditing) return;
if ((event.target as HTMLElement).tagName !== "CANVAS") return;
- const worldPos = screenToWorld(event.clientX, event.clientY);
- if (!worldPos) return;
-
+ const world = viewer.screenToWorld(event.clientX, event.clientY);
const currentZ = currentZIndex * voxelSpacing;
switch (pickingState.activeTool) {
- case "add": {
- // Add new point at click location on current z-slice
- const newPoint: PickingPoint = {
- id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
- x: worldPos.x,
- y: worldPos.y,
+ case "add":
+ addPoint({
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
+ x: world.x,
+ y: world.y,
z: currentZ,
score: 1.0,
- };
- addPoint(newPoint);
+ });
break;
- }
-
case "select": {
- const nearest = findNearestPoint(worldPos.x, worldPos.y);
- if (nearest) {
- selectPoint(nearest.id, event.shiftKey);
- } else if (!event.shiftKey) {
- clearSelection();
- }
+ const nearest = findNearestPoint(world.x, world.y);
+ if (nearest) selectPoint(nearest.id, event.shiftKey);
+ else if (!event.shiftKey) clearSelection();
break;
}
-
case "delete": {
- const nearest = findNearestPoint(worldPos.x, worldPos.y);
- if (nearest) {
- deletePoint(nearest.id);
- }
+ const nearest = findNearestPoint(world.x, world.y);
+ if (nearest) deletePoint(nearest.id);
break;
}
}
},
[
+ viewer,
isEditing,
pickingState.activeTool,
- screenToWorld,
findNearestPoint,
addPoint,
selectPoint,
@@ -168,28 +86,21 @@ export function PickingEventHandler({
]
);
- // Handle wheel for z-navigation (Shift + scroll)
const handleWheel = useCallback(
(event: WheelEvent) => {
if (!event.shiftKey || maxZIndex === undefined) return;
-
- // Only intercept on canvas
if ((event.target as HTMLElement).tagName !== "CANVAS") return;
event.preventDefault();
-
const delta = event.deltaY > 0 ? 1 : -1;
const newIndex = Math.max(0, Math.min(maxZIndex, currentZIndex + delta));
- if (newIndex !== currentZIndex) {
- onZIndexChange(newIndex);
- }
+ if (newIndex !== currentZIndex) onZIndexChange(newIndex);
},
[currentZIndex, maxZIndex, onZIndexChange]
);
- // Attach event listeners
useEffect(() => {
- const canvas = canvasRef.current;
+ const canvas = viewer?.canvas;
if (!canvas) return;
canvas.addEventListener("click", handleClick);
@@ -199,11 +110,10 @@ export function PickingEventHandler({
canvas.removeEventListener("click", handleClick);
canvas.removeEventListener("wheel", handleWheel);
};
- }, [handleClick, handleWheel]);
+ }, [viewer, handleClick, handleWheel]);
- // Update cursor based on tool
useEffect(() => {
- const canvas = canvasRef.current;
+ const canvas = viewer?.canvas;
if (!canvas) return;
if (!isEditing) {
@@ -226,11 +136,9 @@ export function PickingEventHandler({
}
return () => {
- if (canvas) {
- canvas.style.cursor = "";
- }
+ canvas.style.cursor = "";
};
- }, [pickingState.activeTool, isEditing]);
+ }, [viewer, pickingState.activeTool, isEditing]);
return null;
}
diff --git a/client/src/components/viewer/ChannelControls.tsx b/client/src/components/viewer/ChannelControls.tsx
new file mode 100644
index 0000000..cbf0b43
--- /dev/null
+++ b/client/src/components/viewer/ChannelControls.tsx
@@ -0,0 +1,65 @@
+import { Box, IconButton, Paper, Slider } from "@mui/material";
+import { RestartAlt } from "@mui/icons-material";
+
+interface ChannelControlsProps {
+ color: string;
+ contrastLimits: [number, number];
+ contrastRange: [number, number];
+ onColorChange: (color: string) => void;
+ onContrastChange: (limits: [number, number]) => void;
+ onResetContrast: () => void;
+}
+
+const formatValue = (v: number) =>
+ Math.abs(v) >= 1000 || (v !== 0 && Math.abs(v) < 0.01) ? v.toExponential(1) : v.toFixed(2);
+
+export function ChannelControls({
+ color,
+ contrastLimits,
+ contrastRange,
+ onColorChange,
+ onContrastChange,
+ onResetContrast,
+}: ChannelControlsProps) {
+ const [min, max] = contrastRange;
+
+ return (
+
+ onColorChange(e.target.value)}
+ sx={{ width: 24, height: 24, p: 0, border: 0, bgcolor: "transparent", cursor: "pointer" }}
+ />
+ {
+ const [lo, hi] = value as number[];
+ if (lo < hi) onContrastChange([lo, hi]);
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/client/src/components/viewer/TomogramViewer.tsx b/client/src/components/viewer/TomogramViewer.tsx
index 982c25a..b393ecd 100644
--- a/client/src/components/viewer/TomogramViewer.tsx
+++ b/client/src/components/viewer/TomogramViewer.tsx
@@ -1,274 +1,92 @@
-/**
- * Tomogram viewer using idetik-react's OmeZarrChunkedImageViewer.
- *
- * Architecture: Matches umbrella's TomogramViewerView exactly:
- * - IdetikProvider at OUTER level (wraps everything)
- * - Z-index managed as LOCAL state (not context) INSIDE the provider
- * - Uses createImageSourcePolicy (NOT createExplorationPolicy)
- * - Z-index clamped in zProp useMemo
- */
-
-import { useState, useEffect, useMemo, useCallback, memo } from "react";
+import { useCallback, useRef, useState } from "react";
import { Box } from "@mui/material";
-import { createImageSourcePolicy, OmeZarrImageSource, Region } from "@idetik/core";
-import { IdetikProvider, OmeZarrChunkedImageViewer, CustomContrastConfig } from "@idetik/react";
+import { useIdetik } from "@/idetik/useIdetik";
+import { Canvas } from "@/idetik/components/Canvas";
+import { ImageLayer } from "@/idetik/components/ImageLayer";
+import { ScaleBar } from "@/idetik/components/ScaleBar";
import { ViewerProvider } from "@/contexts/ViewerContext";
import { useCopick } from "@/contexts/CopickContext";
import { ViewerControls } from "./ViewerControls";
-import { InteractivePicksOverlay } from "@/components/overlays/InteractivePicksOverlay";
+import { ChannelControls } from "./ChannelControls";
+import type { Contrast } from "@/idetik/contrastStats";
import { SegmentationOverlay } from "@/components/overlays/SegmentationOverlay";
+import { InteractivePicksOverlay } from "@/components/overlays/InteractivePicksOverlay";
import { PickingToolbar } from "@/components/picking/PickingToolbar";
import { PickingEventHandler } from "@/components/picking/PickingEventHandler";
-// Fallback contrast limits if statistics calculation fails
-const FALLBACK_CONTRAST_LIMITS: [number, number] = [-3, 3];
-
-interface ContrastStats {
- mean: number;
- std: number;
- min: number;
- max: number;
-}
-
-/**
- * Calculate statistics from a sample of zarr data using idetik's OmeZarrImageSource.
- * This uses the same data loading pipeline as the viewer, handling compression automatically.
- */
-async function calculateContrastStats(zarrUrl: string): Promise {
- try {
- // Use idetik's OmeZarrImageSource to load data (handles compression)
- const source = OmeZarrImageSource.fromHttp({ url: zarrUrl });
- const loader = await source.open();
-
- // Get dimension info
- const dimMap = loader.getSourceDimensionMap();
- const zDim = dimMap.z;
- if (!zDim) return null;
-
- const midZ = Math.floor(zDim.lods[0].size / 2);
- const zScale = zDim.lods[0].scale;
- const zTranslation = zDim.lods[0].translation;
-
- // Convert array index to world coordinates (idetik Region uses world coords)
- const worldZ = midZ * zScale + zTranslation;
-
- // Load a single z-slice from the middle of the volume
- const region: Region = [
- { dimension: "z", index: { type: "point", value: worldZ } },
- { dimension: "y", index: { type: "full" } },
- { dimension: "x", index: { type: "full" } },
- ];
-
- // Use highest resolution LOD (0)
- const chunk = await loader.loadRegion(region, 0);
-
- if (!chunk.data) return null;
-
- // Calculate statistics from the loaded data
- const data = chunk.data;
- let sum = 0;
- let sumSq = 0;
- let min = Infinity;
- let max = -Infinity;
- const n = data.length;
-
- for (let i = 0; i < n; i++) {
- const v = data[i];
- sum += v;
- sumSq += v * v;
- if (v < min) min = v;
- if (v > max) max = v;
- }
-
- const mean = sum / n;
- const variance = sumSq / n - mean * mean;
- const std = Math.sqrt(Math.max(0, variance));
-
- console.log("[TomogramViewer] Contrast stats calculated:", { mean, std, min, max, sampleSize: n });
-
- return { mean, std, min, max };
- } catch (error) {
- console.error("[TomogramViewer] Failed to calculate contrast stats:", error);
- return null;
- }
-}
-
-interface TomogramViewerProps {
- zarrUrl: string;
-}
-
-interface ZAxisMetadata {
- min: number;
- max: number;
- count: number;
-}
-
-/**
- * Fetch z-axis metadata from zarr array to determine slice count.
- */
-async function getZAxisMetadata(zarrUrl: string): Promise {
- try {
- const arrayResponse = await fetch(`${zarrUrl}/0/.zarray`);
- if (!arrayResponse.ok) return null;
- const arrayInfo = await arrayResponse.json();
- const shape = arrayInfo.shape;
- if (!Array.isArray(shape) || shape.length < 3) return null;
- const zCount = shape[0]; // shape is [z, y, x]
- return { min: 0, max: zCount - 1, count: zCount };
- } catch {
- return null;
- }
-}
-
-/**
- * Outer component: Matches umbrella's TomogramViewerViewInner pattern
- * - IdetikProvider wraps everything (keyed by zarrUrl for remount on URL change)
- * - Content component handles all state management INSIDE the provider
- * - Wrapped in React.memo to prevent re-renders when parent re-renders but props unchanged
- */
-export const TomogramViewer = memo(function TomogramViewer({ zarrUrl }: TomogramViewerProps) {
- return (
-
- {/* IdetikProvider at OUTER level - matches umbrella exactly */}
-
-
-
-
- );
-});
-
-/**
- * Content component: All state management happens INSIDE IdetikProvider
- * This ensures z-index state changes don't affect IdetikProvider's lifecycle
- */
-function TomogramViewerContent({ zarrUrl }: TomogramViewerProps) {
- // Get voxel spacing from context
+export function TomogramViewer({ zarrUrl }: { zarrUrl: string }) {
+ const { viewer, canvasRefCallback } = useIdetik();
const { state: copickState } = useCopick();
const voxelSpacing = copickState.selectedVoxelSize ?? 1;
-
- // LOCAL z-index state (NOT from context) - matches umbrella
- const [currentZIndex, setCurrentZIndex] = useState(0);
- const [zAxisMetadata, setZAxisMetadata] = useState(null);
- const [contrastStats, setContrastStats] = useState(null);
- // STABLE setter from useState - never changes reference
- const [, setZMaxIndex] = useState(undefined);
-
- const fullZarrUrl = `${window.location.origin}${zarrUrl}`;
-
- // Policy with CORRECT function and configuration (EXACTLY like umbrella)
- const customPolicy = useMemo(
- () => createImageSourcePolicy({
- prefetch: { x: 0, y: 0, z: 200 },
- priorityOrder: ['fallbackVisible', 'visibleCurrent', 'fallbackBackground', 'prefetchSpace', 'prefetchTime'],
- lod: { min: 0, max: 1, bias: 1.0 },
- }),
- []
- );
-
- // Fetch z-axis metadata FIRST (fast) - allows viewer to render immediately
- useEffect(() => {
- setZAxisMetadata(null);
- setCurrentZIndex(0);
-
- getZAxisMetadata(fullZarrUrl).then((metadata) => {
- if (metadata) {
- setZAxisMetadata(metadata);
- setCurrentZIndex(Math.floor(metadata.count / 2));
- }
- });
- }, [fullZarrUrl]);
-
- // Calculate contrast stats separately (slower) - updates contrast after viewer is already rendering
- useEffect(() => {
- setContrastStats(null);
-
- calculateContrastStats(fullZarrUrl).then((stats) => {
- if (stats) {
- setContrastStats(stats);
- }
- });
- }, [fullZarrUrl]);
-
- // Compute custom contrast config based on statistics (mean ± 3*std)
- const customContrast = useMemo(() => {
- if (!contrastStats) return undefined;
-
- const { mean, std, min, max } = contrastStats;
- // Contrast limits: mean ± 3*std
- const contrastLimits: [number, number] = [mean - 3 * std, mean + 3 * std];
- // Slider range: use min/max with some padding, or wider range based on std
- const rangeMin = Math.min(min, mean - 5 * std);
- const rangeMax = Math.max(max, mean + 5 * std);
- const contrastRange: [number, number] = [rangeMin, rangeMax];
-
- console.log("[TomogramViewer] Custom contrast config:", { contrastLimits, contrastRange });
-
- return {
- byIndex: {
- 0: { contrastLimits, contrastRange },
- },
- };
- }, [contrastStats]);
-
- // Z-index change handler (passed as prop to ViewerControls)
- const handleZIndexChange = useCallback((newZIndex: number) => {
- if (newZIndex !== currentZIndex && zAxisMetadata) {
- setCurrentZIndex(newZIndex);
+ const [zIndex, setZIndex] = useState(0);
+ const [maxZ, setMaxZ] = useState(undefined);
+
+ const [color, setColor] = useState("#ffffff");
+ const [contrastLimits, setContrastLimits] = useState<[number, number] | undefined>(undefined);
+ const [contrastRange, setContrastRange] = useState<[number, number] | undefined>(undefined);
+ const autoLimitsRef = useRef<[number, number] | null>(null);
+
+ const handleZMaxIndex = useCallback((max: number | undefined) => {
+ setMaxZ(max);
+ if (max !== undefined) {
+ setZIndex(Math.floor(max / 2));
}
- }, [currentZIndex, zAxisMetadata]);
+ }, []);
- // zProp with CLAMPING (EXACTLY like umbrella line 140-151)
- const zProp = useMemo(() => {
- if (!zAxisMetadata || zAxisMetadata.count === 0) {
- return undefined;
+ const handleAutoContrast = useCallback((contrast: Contrast) => {
+ autoLimitsRef.current = contrast.limits;
+ setContrastLimits(contrast.limits);
+ setContrastRange(contrast.range);
+ }, []);
+
+ const handleResetContrast = useCallback(() => {
+ if (autoLimitsRef.current) {
+ setContrastLimits(autoLimitsRef.current);
}
- const maxIndex = zAxisMetadata.count - 1;
- const initIndex = Math.max(0, Math.min(Math.floor(zAxisMetadata.count / 2), maxIndex));
- const clampedZIndex = Math.max(0, Math.min(currentZIndex, maxIndex));
- return {
- initIndex,
- index: clampedZIndex,
- setMaxIndex: setZMaxIndex,
- };
- }, [zAxisMetadata, currentZIndex]);
+ }, []);
return (
-
- {/* Picking toolbar - shows when editing */}
-
-
- {/* Controls: z-index passed via PROPS, not context */}
-
-
- {/* Main viewer area */}
-
- {/* Conditionally render viewer only when z-axis metadata is ready */}
- {zAxisMetadata && zProp && (
-
+
+
+
+
+
+
+
+
+
- )}
- {/* Interactive picks overlay (replaces PicksOverlay) */}
-
- {/* Picking event handler for mouse interactions */}
-
- {/* Segmentation overlay */}
-
+ {contrastLimits && contrastRange && (
+
+
+
+ )}
+
+
+
+
-
+
);
}
diff --git a/client/src/idetik/Idetik.ts b/client/src/idetik/Idetik.ts
new file mode 100644
index 0000000..95800ed
--- /dev/null
+++ b/client/src/idetik/Idetik.ts
@@ -0,0 +1,69 @@
+import { Idetik as IdetikRuntime, Layer, Overlay, OrthographicCamera, PanZoomControls } from "@idetik/core";
+
+export class Idetik {
+ private readonly runtime_: IdetikRuntime;
+ private readonly camera_: OrthographicCamera;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.camera_ = new OrthographicCamera(0, 128, 0, 128, -1000, 1000);
+ this.runtime_ = new IdetikRuntime({
+ canvas,
+ viewports: [{ camera: this.camera_, cameraControls: new PanZoomControls(this.camera_) }],
+ });
+ this.runtime_.start();
+ }
+
+ get canvas(): HTMLCanvasElement {
+ return this.runtime_.canvas;
+ }
+
+ addLayer(layer: Layer): void {
+ this.viewport_.addLayer(layer);
+ }
+
+ removeLayer(layer: Layer): void {
+ if (this.viewport_.layers.includes(layer)) {
+ this.viewport_.removeLayer(layer);
+ }
+ }
+
+ addOverlay(overlay: Overlay): void {
+ this.runtime_.addOverlay(overlay);
+ }
+
+ removeOverlay(overlay: Overlay): void {
+ this.runtime_.removeOverlay(overlay);
+ }
+
+ frameTo(xRange: [number, number], yRange: [number, number]): void {
+ this.camera_.setFrame(xRange[0], xRange[1], yRange[1], yRange[0]);
+ }
+
+ worldPerPixel(): number {
+ return (this.camera_.transform.scale[0] * this.camera_.viewportSize[0]) / this.runtime_.canvas.clientWidth;
+ }
+
+ screenToWorld(clientX: number, clientY: number): { x: number; y: number } {
+ const canvas = this.runtime_.canvas;
+ const rect = canvas.getBoundingClientRect();
+ const ndcX = ((clientX - rect.left) / canvas.clientWidth) * 2 - 1;
+ const ndcY = ((clientY - rect.top) / canvas.clientHeight) * 2 - 1;
+
+ const transform = this.camera_.transform;
+ const widthWorld = transform.scale[0] * this.camera_.viewportSize[0];
+ const heightWorld = transform.scale[1] * this.camera_.viewportSize[1];
+
+ return {
+ x: transform.translation[0] + ndcX * (widthWorld / 2),
+ y: transform.translation[1] + ndcY * (heightWorld / 2),
+ };
+ }
+
+ dispose(): void {
+ this.runtime_.stop();
+ }
+
+ private get viewport_() {
+ return this.runtime_.viewports[0];
+ }
+}
diff --git a/client/src/idetik/PicksLayer.ts b/client/src/idetik/PicksLayer.ts
new file mode 100644
index 0000000..c6dbf83
--- /dev/null
+++ b/client/src/idetik/PicksLayer.ts
@@ -0,0 +1,70 @@
+import { Layer, Points } from "@idetik/core";
+import { vec3 } from "gl-matrix";
+
+type PointProps = ConstructorParameters[0][number];
+
+export interface PicksLayerOptions {
+ points: Array<{ x: number; y: number; z: number }>;
+ color: [number, number, number, number];
+ pointSizePixels: number;
+ zFadeRadius: number;
+}
+
+export class PicksLayer extends Layer {
+ public readonly type = "PicksLayer";
+
+ private readonly points_: Array<{ x: number; y: number; z: number }>;
+ private readonly rgb_: [number, number, number]; // 0-1
+ private readonly pointSizePixels_: number;
+ private readonly zFadeRadius_: number;
+ private currentZ_ = 0;
+ private needsUpdate_ = true;
+
+ constructor({ points, color, pointSizePixels, zFadeRadius }: PicksLayerOptions) {
+ super({ blendMode: "normal" });
+ this.points_ = points;
+ this.rgb_ = [color[0] / 255, color[1] / 255, color[2] / 255];
+ this.pointSizePixels_ = pointSizePixels;
+ this.zFadeRadius_ = zFadeRadius;
+ this.refresh();
+ this.setState("ready");
+ }
+
+ setCurrentZ(z: number): void {
+ if (this.currentZ_ !== z) {
+ this.currentZ_ = z;
+ this.needsUpdate_ = true;
+ }
+ }
+
+ update(): void {
+ if (this.needsUpdate_) {
+ this.refresh();
+ this.needsUpdate_ = false;
+ }
+ }
+
+ private refresh(): void {
+ const [r, g, b] = this.rgb_;
+ const dpr = window.devicePixelRatio || 1;
+ const props: PointProps[] = [];
+
+ for (const pt of this.points_) {
+ const zScale = Math.abs(pt.z - this.currentZ_) / this.zFadeRadius_ + 1.0;
+ const size = (this.pointSizePixels_ * dpr) / zScale;
+ if (size < 0.1) continue;
+
+ props.push({
+ position: vec3.fromValues(pt.x, pt.y, 10),
+ color: [r, g, b, 1.0 / zScale],
+ size,
+ marker: "circle",
+ });
+ }
+
+ this.clearObjects();
+ if (props.length > 0) {
+ this.addObject(new Points(props));
+ }
+ }
+}
diff --git a/client/src/idetik/components/Canvas.tsx b/client/src/idetik/components/Canvas.tsx
new file mode 100644
index 0000000..c60d3f3
--- /dev/null
+++ b/client/src/idetik/components/Canvas.tsx
@@ -0,0 +1,7 @@
+interface CanvasProps {
+ canvasRefCallback: (canvas: HTMLCanvasElement | null) => void;
+}
+
+export function Canvas({ canvasRefCallback }: CanvasProps) {
+ return ;
+}
diff --git a/client/src/idetik/components/ImageLayer.tsx b/client/src/idetik/components/ImageLayer.tsx
new file mode 100644
index 0000000..d6ea007
--- /dev/null
+++ b/client/src/idetik/components/ImageLayer.tsx
@@ -0,0 +1,113 @@
+import { Color, ImageLayer as CoreImageLayer, OmeZarrImageSource, SliceCoordinates } from "@idetik/core";
+import { useEffect, useRef } from "react";
+import { Idetik } from "../Idetik";
+import { imageSourcePolicy } from "../policy";
+import { calculateContrast, type Contrast } from "../contrastStats";
+
+type DimensionLod = { size: number; scale: number; translation: number };
+
+const DEFAULT_CONTRAST_LIMITS: [number, number] = [-3, 3];
+
+interface ImageLayerProps {
+ viewer: Idetik | null;
+ sourceUrl: string;
+ zIndex: number;
+ color: string;
+ contrastLimits: [number, number] | undefined;
+ onZMaxIndex?: (maxIndex: number | undefined) => void;
+ onAutoContrast?: (contrast: Contrast) => void;
+}
+
+function indexToWorld(index: number, lod: DimensionLod): number {
+ return lod.translation + index * lod.scale;
+}
+
+export function ImageLayer({
+ viewer,
+ sourceUrl,
+ zIndex,
+ color,
+ contrastLimits,
+ onZMaxIndex,
+ onAutoContrast,
+}: ImageLayerProps) {
+ const layerRef = useRef(null);
+ const zLodRef = useRef(null);
+ const zIndexRef = useRef(zIndex);
+ const sliceCoordsRef = useRef({});
+ const channelRef = useRef({ color, contrastLimits });
+
+ zIndexRef.current = zIndex;
+ channelRef.current = { color, contrastLimits };
+
+ useEffect(() => {
+ if (!viewer) return;
+
+ let cancelled = false;
+ const abortController = new AbortController();
+
+ (async () => {
+ const source = await OmeZarrImageSource.fromHttp({ url: sourceUrl });
+ if (cancelled) return;
+
+ const dims = source.getDimensions();
+ const zLod = dims.z?.lods[0] ?? null;
+ zLodRef.current = zLod;
+
+ if (zLod) {
+ sliceCoordsRef.current.z = indexToWorld(zIndexRef.current, zLod);
+ }
+
+ const initial = channelRef.current;
+ const layer = new CoreImageLayer({
+ source,
+ sliceCoords: sliceCoordsRef.current,
+ policy: imageSourcePolicy,
+ });
+ layer.setChannelProps([
+ {
+ visible: true,
+ color: Color.fromRgbHex(initial.color),
+ contrastLimits: initial.contrastLimits ?? DEFAULT_CONTRAST_LIMITS,
+ },
+ ]);
+ viewer.addLayer(layer);
+ layerRef.current = layer;
+
+ const xLod = dims.x.lods[0];
+ const yLod = dims.y.lods[0];
+ viewer.frameTo([0, xLod.size * xLod.scale], [0, yLod.size * yLod.scale]);
+
+ onZMaxIndex?.(zLod ? zLod.size - 1 : undefined);
+
+ const contrast = await calculateContrast(source, abortController.signal);
+ if (!cancelled && contrast) {
+ onAutoContrast?.(contrast);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ abortController.abort();
+ if (layerRef.current) viewer.removeLayer(layerRef.current);
+ layerRef.current = null;
+ zLodRef.current = null;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [viewer, sourceUrl, onZMaxIndex, onAutoContrast]);
+
+ useEffect(() => {
+ layerRef.current?.setChannelProps([
+ { visible: true, color: Color.fromRgbHex(color), contrastLimits: contrastLimits ?? DEFAULT_CONTRAST_LIMITS },
+ ]);
+ }, [color, contrastLimits]);
+
+ useEffect(() => {
+ const zLod = zLodRef.current;
+ if (zLod) {
+ sliceCoordsRef.current.z = indexToWorld(zIndex, zLod);
+ }
+ }, [zIndex]);
+
+ return null;
+}
diff --git a/client/src/idetik/components/PointsLayer.tsx b/client/src/idetik/components/PointsLayer.tsx
new file mode 100644
index 0000000..09e2132
--- /dev/null
+++ b/client/src/idetik/components/PointsLayer.tsx
@@ -0,0 +1,42 @@
+import { useEffect, useRef } from "react";
+import { Idetik } from "../Idetik";
+import { PicksLayer } from "../PicksLayer";
+
+type Rgba = [number, number, number, number];
+type Point = { x: number; y: number; z: number };
+
+const Z_FADE_RADIUS = 64.0;
+
+interface PointsLayerProps {
+ viewer: Idetik;
+ points: Point[] | undefined;
+ color: Rgba | undefined;
+ pointSizePixels: number;
+ worldZ: number;
+}
+
+export function PointsLayer({ viewer, points, color, pointSizePixels, worldZ }: PointsLayerProps) {
+ const layerRef = useRef(null);
+ const worldZRef = useRef(worldZ);
+ worldZRef.current = worldZ;
+
+ useEffect(() => {
+ if (!points || points.length === 0 || !color) return;
+
+ const layer = new PicksLayer({ points, color, pointSizePixels, zFadeRadius: Z_FADE_RADIUS });
+ layer.setCurrentZ(worldZRef.current);
+ viewer.addLayer(layer);
+ layerRef.current = layer;
+
+ return () => {
+ viewer.removeLayer(layer);
+ layerRef.current = null;
+ };
+ }, [viewer, points, color, pointSizePixels]);
+
+ useEffect(() => {
+ layerRef.current?.setCurrentZ(worldZ);
+ }, [worldZ]);
+
+ return null;
+}
diff --git a/client/src/idetik/components/ScaleBar.tsx b/client/src/idetik/components/ScaleBar.tsx
new file mode 100644
index 0000000..cad23ae
--- /dev/null
+++ b/client/src/idetik/components/ScaleBar.tsx
@@ -0,0 +1,126 @@
+import { Overlay } from "@idetik/core";
+import { useEffect, useRef } from "react";
+import { Idetik } from "../Idetik";
+
+type Align = "start" | "center" | "end";
+
+const unitAbbreviations = new Map([
+ ["angstrom", "Å"],
+ ["attometer", "am"],
+ ["centimeter", "cm"],
+ ["decimeter", "dm"],
+ ["exameter", "Em"],
+ ["femtometer", "fm"],
+ ["foot", "ft"],
+ ["gigameter", "Gm"],
+ ["hectometer", "hm"],
+ ["inch", "in"],
+ ["kilometer", "km"],
+ ["megameter", "Mm"],
+ ["meter", "m"],
+ ["micrometer", "µm"],
+ ["mile", "mi"],
+ ["millimeter", "mm"],
+ ["nanometer", "nm"],
+ ["parsec", "pc"],
+ ["petameter", "Pm"],
+ ["picometer", "pm"],
+ ["terameter", "Tm"],
+ ["yard", "yd"],
+ ["yoctometer", "ym"],
+ ["yottameter", "Ym"],
+ ["zeptometer", "zm"],
+ ["zettameter", "Zm"],
+]);
+
+function getUnitAbbreviation(unit: string | undefined): string {
+ if (unit === undefined) return "";
+ return unitAbbreviations.get(unit.toLowerCase()) ?? unit;
+}
+
+class ScientificNumber {
+ constructor(
+ readonly mantissa: number,
+ readonly exponent: number
+ ) {}
+
+ value(): number {
+ return this.mantissa * Math.pow(10, this.exponent);
+ }
+
+ format(): string {
+ if (this.exponent >= -2) {
+ return this.value().toFixed(Math.max(0, -this.exponent));
+ }
+ return this.value().toExponential(2);
+ }
+
+ static floor(x: number): ScientificNumber {
+ if (x === 0) return new ScientificNumber(0, 0);
+ const exponent = Math.floor(Math.log10(Math.abs(x)));
+ return new ScientificNumber(Math.floor(x / Math.pow(10, exponent)), exponent);
+ }
+}
+
+const ITEMS_CLASS: Record = {
+ start: "items-start",
+ center: "items-center",
+ end: "items-end",
+};
+
+const TEXT_CLASS: Record = {
+ start: "text-start",
+ center: "text-center",
+ end: "text-end",
+};
+
+interface ScaleBarProps {
+ viewer: Idetik | null;
+ unit?: string;
+ align?: Align;
+}
+
+export function ScaleBar({ viewer, unit, align = "start" }: ScaleBarProps) {
+ const containerRef = useRef(null);
+ const textRef = useRef(null);
+ const lineRef = useRef(null);
+
+ const unitAbbreviation = getUnitAbbreviation(unit);
+
+ useEffect(() => {
+ if (!viewer) return;
+
+ let lastWidthWorld: number | undefined;
+ const overlay: Overlay = {
+ update() {
+ const container = containerRef.current;
+ const line = lineRef.current;
+ const text = textRef.current;
+ if (!container || !line || !text) return;
+
+ const widthWorld = container.clientWidth * viewer.worldPerPixel();
+ if (widthWorld === lastWidthWorld) return;
+ lastWidthWorld = widthWorld;
+
+ const nice = ScientificNumber.floor(widthWorld);
+ line.style.width = `${(nice.value() / widthWorld) * 100}%`;
+ text.textContent = `${nice.format()} ${unitAbbreviation}`;
+ },
+ };
+
+ viewer.addOverlay(overlay);
+ return () => {
+ viewer.removeOverlay(overlay);
+ };
+ }, [viewer, unitAbbreviation]);
+
+ return (
+
+ );
+}
diff --git a/client/src/idetik/components/SegmentationLayer.tsx b/client/src/idetik/components/SegmentationLayer.tsx
new file mode 100644
index 0000000..530d216
--- /dev/null
+++ b/client/src/idetik/components/SegmentationLayer.tsx
@@ -0,0 +1,54 @@
+import { LabelLayer, OmeZarrImageSource, SliceCoordinates } from "@idetik/core";
+import { useEffect, useRef } from "react";
+import { Idetik } from "../Idetik";
+import { imageSourcePolicy } from "../policy";
+
+type Rgba = [number, number, number, number];
+
+interface SegmentationLayerProps {
+ viewer: Idetik;
+ sourceUrl: string;
+ lookupTable: Map;
+ worldZ: number;
+}
+
+export function SegmentationLayer({ viewer, sourceUrl, lookupTable, worldZ }: SegmentationLayerProps) {
+ const sliceCoordsRef = useRef({});
+ const lookupTableRef = useRef(lookupTable);
+ const worldZRef = useRef(worldZ);
+
+ lookupTableRef.current = lookupTable;
+ worldZRef.current = worldZ;
+
+ useEffect(() => {
+ let cancelled = false;
+ let layer: LabelLayer | null = null;
+
+ (async () => {
+ const source = await OmeZarrImageSource.fromHttp({ url: sourceUrl });
+ if (cancelled) return;
+
+ sliceCoordsRef.current.z = worldZRef.current;
+ layer = new LabelLayer({
+ source,
+ sliceCoords: sliceCoordsRef.current,
+ colorMap: { lookupTable: lookupTableRef.current },
+ blendMode: "normal",
+ policy: imageSourcePolicy,
+ });
+ viewer.addLayer(layer);
+ })();
+
+ return () => {
+ cancelled = true;
+ if (layer) viewer.removeLayer(layer);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [viewer, sourceUrl]);
+
+ useEffect(() => {
+ sliceCoordsRef.current.z = worldZ;
+ }, [worldZ]);
+
+ return null;
+}
diff --git a/client/src/idetik/contrastStats.ts b/client/src/idetik/contrastStats.ts
new file mode 100644
index 0000000..f9da798
--- /dev/null
+++ b/client/src/idetik/contrastStats.ts
@@ -0,0 +1,75 @@
+import { OmeZarrImageSource } from "@idetik/core";
+
+type Chunk = Parameters[0];
+
+export interface Contrast {
+ limits: [number, number];
+ range: [number, number];
+}
+
+export async function calculateContrast(
+ source: OmeZarrImageSource,
+ signal: AbortSignal
+): Promise {
+ try {
+ const dims = source.getDimensions();
+ const lod = 0;
+ const xLod = dims.x.lods[lod];
+ const yLod = dims.y.lods[lod];
+ const zLod = dims.z?.lods[lod];
+ if (!zLod) return null;
+
+ const chunkIndex = {
+ x: Math.floor(xLod.size / 2 / xLod.chunkSize),
+ y: Math.floor(yLod.size / 2 / yLod.chunkSize),
+ z: Math.floor(zLod.size / 2 / zLod.chunkSize),
+ c: 0,
+ t: 0,
+ };
+
+ const chunk: Chunk = {
+ state: "unloaded",
+ lod,
+ shape: {
+ x: Math.min(xLod.chunkSize, xLod.size - chunkIndex.x * xLod.chunkSize),
+ y: Math.min(yLod.chunkSize, yLod.size - chunkIndex.y * yLod.chunkSize),
+ z: Math.min(zLod.chunkSize, zLod.size - chunkIndex.z * zLod.chunkSize),
+ c: 1,
+ },
+ chunkIndex,
+ scale: { x: xLod.scale, y: yLod.scale, z: zLod.scale },
+ offset: { x: 0, y: 0, z: 0 },
+ rowAlignmentBytes: 1,
+ visible: false,
+ prefetch: false,
+ priority: null,
+ orderKey: null,
+ };
+
+ await source.loader.loadChunkData(chunk, signal);
+
+ const data = chunk.data;
+ if (!data) return null;
+
+ let sum = 0;
+ let sumSq = 0;
+ const n = data.length;
+ for (let i = 0; i < n; i++) {
+ const v = data[i];
+ sum += v;
+ sumSq += v * v;
+ }
+
+ const mean = sum / n;
+ const std = Math.sqrt(Math.max(0, sumSq / n - mean * mean));
+ return {
+ limits: [mean - 3 * std, mean + 3 * std],
+ range: [mean - 5 * std, mean + 5 * std],
+ };
+ } catch (error) {
+ if (!signal.aborted) {
+ console.error("[contrastStats] Failed to calculate contrast:", error);
+ }
+ return null;
+ }
+}
diff --git a/client/src/idetik/policy.ts b/client/src/idetik/policy.ts
new file mode 100644
index 0000000..a9def10
--- /dev/null
+++ b/client/src/idetik/policy.ts
@@ -0,0 +1,7 @@
+import { createImageSourcePolicy } from "@idetik/core";
+
+export const imageSourcePolicy = createImageSourcePolicy({
+ prefetch: { x: 0, y: 0, z: 200 },
+ priorityOrder: ["fallbackVisible", "visibleCurrent", "fallbackBackground", "prefetchSpace", "prefetchTime"],
+ lod: { min: 0, max: 1, bias: 1.0 },
+});
diff --git a/client/src/idetik/useIdetik.ts b/client/src/idetik/useIdetik.ts
new file mode 100644
index 0000000..30a3b74
--- /dev/null
+++ b/client/src/idetik/useIdetik.ts
@@ -0,0 +1,33 @@
+import { useCallback, useRef, useState } from "react";
+import { Idetik } from "./Idetik";
+
+export function useIdetik() {
+ const viewerRef = useRef(null);
+ const [viewer, setViewer] = useState(null);
+
+ const canvasRefCallback = useCallback((canvas: HTMLCanvasElement | null) => {
+ const current = viewerRef.current;
+
+ if (canvas === null) {
+ if (current !== null) {
+ current.dispose();
+ viewerRef.current = null;
+ setViewer(null);
+ }
+ return;
+ }
+
+ if (current !== null) {
+ if (current.canvas !== canvas) {
+ throw new Error("Only one viewer canvas can be mounted at a time.");
+ }
+ return;
+ }
+
+ const created = new Idetik(canvas);
+ viewerRef.current = created;
+ setViewer(created);
+ }, []);
+
+ return { viewer, canvasRefCallback };
+}
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
index 07f2171..f9fa5a6 100644
--- a/client/tailwind.config.js
+++ b/client/tailwind.config.js
@@ -7,7 +7,6 @@ export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
- "./node_modules/@idetik/react/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: sds,
diff --git a/client/vite.config.ts b/client/vite.config.ts
index a5a7f6a..82f8766 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -16,6 +16,9 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
+ optimizeDeps: {
+ exclude: ["@idetik/core"],
+ },
server: {
port: 5173,
hmr: hmrClientPort ? { clientPort: hmrClientPort } : undefined,