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,