diff --git a/.gitignore b/.gitignore index 5e84f83..9bc56fb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ apps/probe-viewer/public/data/ apps/probe-viewer/public/probes-manifest.json apps/probe-viewer/node_modules/ apps/probe-viewer/dist/ +apps/probe-viewer/.vite/ # Build cache .cache/ diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index 3b98a2a..ff5e061 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -128,6 +128,99 @@ color: #475569; } +/* Neuropixels hierarchy grouping */ +.sidebar-group { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.sidebar-group-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.45rem 0.4rem; + border: none; + background: transparent; + cursor: pointer; + color: #0f172a; + font-weight: 700; + font-size: 0.95rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.35); +} + +.sidebar-group-header:hover { + color: #2563eb; +} + +.sidebar-group-caret { + width: 1rem; + color: #64748b; +} + +.sidebar-group-title { + flex: 1; + text-align: left; +} + +.sidebar-group-count { + font-size: 0.75rem; + font-weight: 600; + color: #475569; + background: rgba(148, 163, 184, 0.25); + border-radius: 999px; + padding: 0.05rem 0.5rem; +} + +.sidebar-subgroup { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding-left: 0.6rem; +} + +.sidebar-subgroup-header { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + padding: 0.3rem 0.25rem; + border: none; + background: transparent; + cursor: pointer; +} + +.sidebar-subgroup-header:hover .sidebar-subgroup-title { + color: #2563eb; +} + +.sidebar-subgroup-title { + flex: 1; + text-align: left; + font-size: 0.74rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #64748b; +} + +.sidebar-subdivision { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +/* Static (non-collapsible) length divider inside the Non-human-primate family. */ +.sidebar-subgroup-divider { + margin: 0.3rem 0 0.05rem 0.9rem; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #94a3b8; +} + .sidebar-hint { font-size: 0.9rem; color: #64748b; diff --git a/apps/probe-viewer/src/App.tsx b/apps/probe-viewer/src/App.tsx index 3368bbd..ed9e109 100644 --- a/apps/probe-viewer/src/App.tsx +++ b/apps/probe-viewer/src/App.tsx @@ -1,183 +1,28 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { - useLocation, - useNavigate, - useParams, - useSearchParams, -} from "react-router-dom"; +import { useEffect } from "react"; import { ProbeViewer } from "./components/ProbeViewer"; import { Sidebar } from "./components/Sidebar"; import { useAppStore } from "./state/useAppStore"; +import { useProbeRouteSync } from "./state/useProbeRouteSync"; +import { useRestoreCameraFromUrl } from "./state/useRestoreCameraFromUrl"; +import { useSyncCameraToUrl } from "./state/useSyncCameraToUrl"; import "./App.css"; -const DEFAULT_PROBE_ID = "plexon:8S1024"; - -function roundForUrl(value: number, decimals = 1): number { - const factor = Math.pow(10, decimals); - return Math.round(value * factor) / factor; -} - function App() { - const { manufacturer, model } = useParams(); - const location = useLocation(); - const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - - const manifestStatus = useAppStore((state) => state.manifestStatus); - const manifest = useAppStore((state) => state.manifest); - const selectedProbeId = useAppStore((state) => state.selectedProbeId); const loadManifest = useAppStore((state) => state.loadManifest); - const selectProbe = useAppStore((state) => state.selectProbe); - - const view = useAppStore((state) => state.view); - const setZoom = useAppStore((state) => state.setZoom); - const setViewCenter = useAppStore((state) => state.setViewCenter); useEffect(() => { void loadManifest(); }, [loadManifest]); - // Track whether we've initialized from URL to avoid overwriting on first render - const initializedFromUrl = useRef(false); - - // Read view params from URL on initial load - useEffect(() => { - if (initializedFromUrl.current) return; - initializedFromUrl.current = true; - - const zoomParam = searchParams.get("zoom"); - const cxParam = searchParams.get("cx"); - const cyParam = searchParams.get("cy"); - - if (zoomParam) { - const zoom = parseFloat(zoomParam); - if (!isNaN(zoom)) setZoom(zoom); - } - if (cxParam && cyParam) { - const cx = parseFloat(cxParam); - const cy = parseFloat(cyParam); - if (!isNaN(cx) && !isNaN(cy)) setViewCenter(cx, cy); - } - }, [searchParams, setZoom, setViewCenter]); - - // Debounced URL update when view state changes - const updateUrlTimeout = useRef | undefined>(undefined); - const updateSearchParams = useCallback(() => { - const { zoom, viewCenterX, viewCenterY } = view; - const isDefault = zoom === 1 && viewCenterX === null && viewCenterY === null; - - setSearchParams((prev) => { - const next = new URLSearchParams(prev); - if (isDefault) { - next.delete("zoom"); - next.delete("cx"); - next.delete("cy"); - } else { - next.set("zoom", String(roundForUrl(zoom, 2))); - if (viewCenterX !== null && viewCenterY !== null) { - next.set("cx", String(roundForUrl(viewCenterX, 1))); - next.set("cy", String(roundForUrl(viewCenterY, 1))); - } else { - next.delete("cx"); - next.delete("cy"); - } - } - return next; - }, { replace: true }); - }, [view, setSearchParams]); - - useEffect(() => { - if (!initializedFromUrl.current) return; - - clearTimeout(updateUrlTimeout.current); - updateUrlTimeout.current = setTimeout(updateSearchParams, 300); - - return () => clearTimeout(updateUrlTimeout.current); - }, [view.zoom, view.viewCenterX, view.viewCenterY, updateSearchParams]); - - const manifestById = useMemo(() => { - const map = new Map(); - manifest.forEach((entry) => map.set(entry.id, entry)); - return map; - }, [manifest]); - - useEffect(() => { - if (manifestStatus !== "success" || manifest.length === 0) { - return; - } - - const routeId = - manufacturer && model ? `${manufacturer}:${model}` : undefined; - const routeEntry = routeId ? manifestById.get(routeId) : undefined; - const currentSelected = selectedProbeId - ? manifestById.get(selectedProbeId) - : undefined; - - const getDefaultProbe = () => - manifestById.get(DEFAULT_PROBE_ID) ?? manifest[0]; - - if (selectedProbeId && !currentSelected) { - const fallback = routeEntry ?? getDefaultProbe(); - if (fallback && fallback.id !== selectedProbeId) { - selectProbe(fallback.id); - } - return; - } - - if (!selectedProbeId) { - if (routeEntry) { - selectProbe(routeEntry.id); - } else { - const fallback = getDefaultProbe(); - if (fallback) { - selectProbe(fallback.id); - } - } - } - }, [ - manifestStatus, - manifest, - manifestById, - manufacturer, - model, - selectedProbeId, - selectProbe, - ]); - - useEffect(() => { - if ( - manifestStatus !== "success" || - !selectedProbeId || - manifest.length === 0 - ) { - return; - } - - const selectedEntry = manifestById.get(selectedProbeId); - if (!selectedEntry) { - return; - } - - const routeId = - manufacturer && model ? `${manufacturer}:${model}` : undefined; - if (routeId === selectedEntry.id) { - return; - } + // Selected probe <-> URL path (/probes/:manufacturer/:model). + useProbeRouteSync(); - const targetPath = `/probes/${selectedEntry.manufacturer}/${selectedEntry.model}`; - const replace = location.pathname === "/"; - navigate(targetPath, { replace }); - }, [ - manifestStatus, - selectedProbeId, - manifestById, - manufacturer, - model, - navigate, - location.pathname, - manifest.length, - ]); + // Camera <-> URL query string: restore from a shared link on load, then keep + // the URL updated as the user zooms/pans. Coordinated via the store's + // `cameraInitialized` flag so the writer can't clobber the link at mount. + useRestoreCameraFromUrl(); + useSyncCameraToUrl(); return (
diff --git a/apps/probe-viewer/src/components/ProbeCanvas.tsx b/apps/probe-viewer/src/components/ProbeCanvas.tsx index d9327ba..8059139 100644 --- a/apps/probe-viewer/src/components/ProbeCanvas.tsx +++ b/apps/probe-viewer/src/components/ProbeCanvas.tsx @@ -14,14 +14,17 @@ import type { import { useResizeObserver } from "../hooks/useResizeObserver"; import { VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore"; -import type { ContactShapeParams, ManifestEntry, ProbeInterfaceFile } from "../types/probe"; +import type { + ContactShapeParams, + ManifestEntry, + ProbeInterfaceFile, + ProbeViewerCamera, +} from "../types/probe"; interface ProbeCanvasProps { entry: ManifestEntry; probeData: ProbeInterfaceFile; - zoom: number; - viewCenterX: number | null; // probe coordinates (µm), null = geometry center - viewCenterY: number | null; + camera: ProbeViewerCamera; showContactIds: boolean; showScaleBar: boolean; onViewCenterChange: (x: number | null, y: number | null) => void; @@ -79,9 +82,7 @@ export const ProbeCanvas = forwardRef( { entry, probeData, - zoom, - viewCenterX, - viewCenterY, + camera, showContactIds, showScaleBar, onViewCenterChange, @@ -89,6 +90,7 @@ export const ProbeCanvas = forwardRef( }, ref ) { + const { zoom, centerX, centerY } = camera; const canvasRef = useRef(null); // Expose canvas to parent for export @@ -109,8 +111,8 @@ export const ProbeCanvas = forwardRef( const probe = useMemo(() => probeData.probes?.[0], [probeData]); // Calculate effective view center (use geometry center if null) - const effectiveViewCenterX = viewCenterX ?? geometry?.centerX ?? 0; - const effectiveViewCenterY = viewCenterY ?? geometry?.centerY ?? 0; + const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0; + const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0; useEffect(() => { if (!canvasRef.current || !size.width || !size.height || !geometry || !probe) { @@ -568,8 +570,8 @@ export const ProbeCanvas = forwardRef( // read it directly (instead of parsing the hash). These mirror the URL // params: cx/cy are omitted at the default view, exactly like the URL. data-zoom={zoom} - data-view-cx={viewCenterX ?? undefined} - data-view-cy={viewCenterY ?? undefined} + data-view-cx={centerX ?? undefined} + data-view-cy={centerY ?? undefined} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} diff --git a/apps/probe-viewer/src/components/ProbeOverview.tsx b/apps/probe-viewer/src/components/ProbeOverview.tsx index 5fb6edc..8ee3da5 100644 --- a/apps/probe-viewer/src/components/ProbeOverview.tsx +++ b/apps/probe-viewer/src/components/ProbeOverview.tsx @@ -1,11 +1,9 @@ import { useEffect, useRef, useMemo } from "react"; -import type { ProbeInterfaceFile } from "../types/probe"; +import type { ProbeInterfaceFile, ProbeViewerCamera } from "../types/probe"; interface ProbeOverviewProps { probeData: ProbeInterfaceFile; - zoom: number; - viewCenterX: number | null; // probe coordinates (µm), null = geometry center - viewCenterY: number | null; + camera: ProbeViewerCamera; /** Main canvas dimensions */ mainWidth: number; mainHeight: number; @@ -53,20 +51,19 @@ function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary export function ProbeOverview({ probeData, - zoom, - viewCenterX, - viewCenterY, + camera, mainWidth, mainHeight, onViewCenterChange, }: ProbeOverviewProps) { + const { zoom, centerX, centerY } = camera; const canvasRef = useRef(null); const geometry = useMemo(() => computeGeometrySummary(probeData), [probeData]); const probe = useMemo(() => probeData.probes?.[0], [probeData]); // Calculate effective view center (use geometry center if null) - const effectiveViewCenterX = viewCenterX ?? geometry?.centerX ?? 0; - const effectiveViewCenterY = viewCenterY ?? geometry?.centerY ?? 0; + const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0; + const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0; // Fixed minimap size const MINIMAP_WIDTH = 120; diff --git a/apps/probe-viewer/src/components/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx index d081039..ae11786 100644 --- a/apps/probe-viewer/src/components/ProbeViewer.tsx +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -94,25 +94,25 @@ export function ProbeViewer() { if (probeData && entry) { exportProbeAsPng( probeData, - { zoom: view.zoom, viewCenterX: view.viewCenterX, viewCenterY: view.viewCenterY }, + view.camera, { width: canvasSize.width, height: canvasSize.height }, `${entry.id}.png`, view.showScaleBar ); } - }, [probeData, entry, view.zoom, view.viewCenterX, view.viewCenterY, canvasSize.width, canvasSize.height, view.showScaleBar]); + }, [probeData, entry, view.camera, canvasSize.width, canvasSize.height, view.showScaleBar]); const handleExportSvg = useCallback(() => { if (probeData && entry) { exportProbeAsSvg( probeData, - { zoom: view.zoom, viewCenterX: view.viewCenterX, viewCenterY: view.viewCenterY }, + view.camera, { width: canvasSize.width, height: canvasSize.height }, `${entry.id}.svg`, view.showScaleBar ); } - }, [probeData, entry, view.zoom, view.viewCenterX, view.viewCenterY, canvasSize.width, canvasSize.height, view.showScaleBar]); + }, [probeData, entry, view.camera, canvasSize.width, canvasSize.height, view.showScaleBar]); const [shareCopied, setShareCopied] = useState(false); const handleShareView = useCallback(() => { @@ -129,8 +129,8 @@ export function ProbeViewer() { // Get current view state directly from store (not stale closure value) // This is critical because App.tsx's URL effect may have updated the store // after this component rendered but before this effect runs - const currentView = useAppStore.getState().view; - const hasUrlViewState = currentView.zoom !== 1 || currentView.viewCenterX !== null || currentView.viewCenterY !== null; + const currentCamera = useAppStore.getState().view.camera; + const hasUrlViewState = currentCamera.zoom !== 1 || currentCamera.centerX !== null || currentCamera.centerY !== null; if (!hasUrlViewState) { resetView(); } @@ -152,8 +152,8 @@ export function ProbeViewer() { if (canvasSize.width === 0 || canvasSize.height === 0) return; // Get current view state directly from store (not stale closure value) - const currentView = useAppStore.getState().view; - const hasUrlViewState = currentView.zoom !== 1 || currentView.viewCenterX !== null || currentView.viewCenterY !== null; + const currentCamera = useAppStore.getState().view.camera; + const hasUrlViewState = currentCamera.zoom !== 1 || currentCamera.centerX !== null || currentCamera.centerY !== null; if (hasUrlViewState) { lastSmartZoomProbeId.current = selectedProbeId; return; @@ -279,14 +279,14 @@ export function ProbeViewer() {
+ ); + + // Renders one node of the grouped tree at any depth. A collapsible node is a + // toggle header; a non-collapsible one is a static divider. Open nodes recurse + // into their children, or list their entries when they are leaves. + const renderNode = (node: GroupNode, depth: number, parentPath: string[]) => { + const path = [...parentPath, node.label]; + const key = path.join("||"); + const open = !node.collapsible || isSearching || expanded.has(key); + const wrapClass = + depth === 0 + ? "sidebar-group" + : depth === 1 + ? "sidebar-subgroup" + : "sidebar-subdivision"; + return ( +
+ {node.collapsible ? ( + + ) : ( +

{node.label}

+ )} + {open && + (node.children + ? node.children.map((child) => renderNode(child, depth + 1, path)) + : node.entries?.map((entry) => renderItem(entry)))} +
+ ); + }; + return (
@@ -110,23 +204,14 @@ export function Sidebar() { {manifestStatus === "success" && filteredEntries.length === 0 && (

No probes match the current filters.

)} - {filteredEntries.map((entry) => ( - - ))} + + {manifestStatus === "success" && + !groups && + filteredEntries.map((entry) => renderItem(entry))} + + {manifestStatus === "success" && + groups && + groups.map((node) => renderNode(node, 0, []))}
); diff --git a/apps/probe-viewer/src/grouping/README.md b/apps/probe-viewer/src/grouping/README.md new file mode 100644 index 0000000..2c05602 --- /dev/null +++ b/apps/probe-viewer/src/grouping/README.md @@ -0,0 +1,40 @@ +# Sidebar grouping + +The probe sidebar turns a manufacturer's flat probe list into a hierarchy from +an explicit, hand-curated config. The goal is a more human-friendly menu: users +may not know the probes' SKUs and instead recognize the marketing names and +categories. There are no rules and no inference: every +probe is placed by model id under a path of named nodes. A manufacturer with no +config (everything except IMEC today) renders as a flat list. + +## Format + +JSON (`imec_neuropixels.json`), because Vite imports it with no extra +dependency. The config is `{ hierarchy }`, a tree of nodes. Each node has a +`label` and either `children` (more nodes) or `probes` (model ids, in display +order) at the leaves. + +`collapsible` is a per-node property that **defaults to `true`**, so only the +exceptions need stating: the length bands set `collapsible: false`, while every +other node omits it. `true` is a foldable header with a caret; `false` is a +static, always-open divider. This is useful for divisions worth mentioning but +not worth occluding, usually the finer levels, like the length bands of the +non-human-primate (NHP) probes in Neuropixels. + +The walker (`groupEntries.ts`) attaches each manifest entry to the node listing +its model, prunes empty branches, and collects anything not placed into a +trailing `Ungrouped` group. That bucket is the signal that a probe new to the +manifest needs a home in the config. + +## Adding or moving a probe + +Edit the relevant `probes` list. To add a new manufacturer, add its JSON file +and one line in `index.ts`. No engine changes. JSON has no comments, so if a +placement is non-obvious, record the rationale in a `note` field on the node +(documentation only, not rendered). For example, the Neuropixels NXT node: + +```json +{ "label": "Neuropixels NXT", + "note": "NP3023/NP3024 describe themselves as 'Neuropixels 3.0' but belong to the NXT generation.", + "children": [ ... ] } +``` diff --git a/apps/probe-viewer/src/grouping/groupEntries.ts b/apps/probe-viewer/src/grouping/groupEntries.ts new file mode 100644 index 0000000..b812037 --- /dev/null +++ b/apps/probe-viewer/src/grouping/groupEntries.ts @@ -0,0 +1,60 @@ +import type { ManifestEntry } from "../types/probe"; +import type { GroupNode, HierarchyConfig, DisplayCategory } from "./types"; + +// Label for the trailing bucket that catches any probe present in the manifest +// but not placed anywhere in the hierarchy. It is the visible signal that a new +// probe needs a home in the config. +const UNGROUPED_LABEL = "Ungrouped"; + +// Resolves the explicit hierarchy against the given (already search-filtered) +// entries: attaches each entry to the node that lists its model id, prunes +// empty branches, and gathers anything unplaced into a trailing "Ungrouped" +// group. `collapsible` is per node and defaults to true. +export function groupEntries( + entries: ManifestEntry[], + config: HierarchyConfig, +): GroupNode[] { + const byModel = new Map(); + for (const entry of entries) byModel.set(entry.model, entry); + const placed = new Set(); + + const walk = (node: DisplayCategory): GroupNode | null => { + const collapsible = node.collapsible ?? true; + + if (node.children) { + const children = node.children + .map((child) => walk(child)) + .filter((child): child is GroupNode => child !== null); + if (children.length === 0) return null; + const count = children.reduce((sum, child) => sum + child.count, 0); + return { label: node.label, collapsible, count, children }; + } + + const found: ManifestEntry[] = []; + for (const model of node.probes ?? []) { + const entry = byModel.get(model); + if (entry) { + found.push(entry); + placed.add(model); + } + } + if (found.length === 0) return null; + return { label: node.label, collapsible, count: found.length, entries: found }; + }; + + const groups = config.hierarchy + .map((node) => walk(node)) + .filter((group): group is GroupNode => group !== null); + + const leftovers = entries.filter((entry) => !placed.has(entry.model)); + if (leftovers.length > 0) { + groups.push({ + label: UNGROUPED_LABEL, + collapsible: true, + count: leftovers.length, + entries: leftovers, + }); + } + + return groups; +} diff --git a/apps/probe-viewer/src/grouping/imec_neuropixels.json b/apps/probe-viewer/src/grouping/imec_neuropixels.json new file mode 100644 index 0000000..857f8fc --- /dev/null +++ b/apps/probe-viewer/src/grouping/imec_neuropixels.json @@ -0,0 +1,35 @@ +{ + "hierarchy": [ + { "label": "Neuropixels 1.0", + "children": [ + { "label": "Standard", "probes": ["NP1000", "NP1001"] }, + { "label": "Non-human-primate", + "children": [ + { "label": "short", "collapsible": false, "probes": ["NP1010", "NP1011", "NP1012", "NP1013", "NP1014", "NP1015", "NP1016", "NP1017"] }, + { "label": "medium", "collapsible": false, "probes": ["NP1020", "NP1021", "NP1022"] }, + { "label": "long / max", "collapsible": false, "probes": ["NP1030", "NP1031", "NP1032", "NP1033", "NP1040", "NP1041", "NP1042", "NP1050", "NP1051"] }, + { "label": "passive", "collapsible": false, "probes": ["NP1200", "NP1210"] } + ] }, + { "label": "Ultra High Density", "probes": ["NP1100", "NP1120", "NP1121", "NP1122", "NP1123"] }, + { "label": "Optogenetics", "probes": ["NP1300"] }, + { "label": "Legacy", "probes": ["PRB_1_2_0480_2", "PRB_1_4_0480_1", "PRB_1_4_0480_1_C"] } + ] }, + { "label": "Neuropixels 2.0", + "children": [ + { "label": "Non-human-primate", + "children": [ + { "label": "short", "collapsible": false, "probes": ["NP2005", "NP2006"] } + ] }, + { "label": "Single-shank", "probes": ["NP2000", "NP2003", "NP2004"] }, + { "label": "Multi-shank", "probes": ["NP2010", "NP2013", "NP2014", "NP2020", "NP2021"] }, + { "label": "Legacy", "probes": ["PRB2_1_2_0640_0", "PRB2_4_2_0640_0"] } + ] }, + { "label": "Neuropixels NXT", + "note": "NP3023/NP3024 describe themselves as 'Neuropixels 3.0' but I am gruping them here as the NXT generation.", + "children": [ + { "label": "Single-shank", "probes": ["NP3010", "NP3011"] }, + { "label": "Multi-shank", "probes": ["NP3020", "NP3021", "NP3022", "NP3023", "NP3024"] }, + { "label": "Passive", "probes": ["NP3000"] } + ] } + ] +} diff --git a/apps/probe-viewer/src/grouping/index.ts b/apps/probe-viewer/src/grouping/index.ts new file mode 100644 index 0000000..28b110d --- /dev/null +++ b/apps/probe-viewer/src/grouping/index.ts @@ -0,0 +1,17 @@ +import type { HierarchyConfig } from "./types"; +import imecHierarchy from "./imec_neuropixels.json"; + +// Registry mapping a manufacturer key (as it appears in the manifest) to its +// explicit sidebar hierarchy. A manufacturer absent here has no hierarchy and +// renders as a flat list, which is the default for every manufacturer other +// than IMEC. Adding grouping for a new manufacturer is one JSON file plus one +// line here, with no engine changes. +// +// JSON is used because Vite imports it with no extra dependency. +const REGISTRY: Record = { + imec: imecHierarchy as HierarchyConfig, +}; + +export function getGroupingConfig(manufacturer: string): HierarchyConfig | undefined { + return REGISTRY[manufacturer]; +} diff --git a/apps/probe-viewer/src/grouping/types.ts b/apps/probe-viewer/src/grouping/types.ts new file mode 100644 index 0000000..3d6f8aa --- /dev/null +++ b/apps/probe-viewer/src/grouping/types.ts @@ -0,0 +1,37 @@ +import type { ManifestEntry } from "../types/probe"; + +// Explicit, hand-curated sidebar hierarchy for one manufacturer. There are no +// rules: every probe is placed by model id under the tree in imec.json, with +// model ids at the leaves. A manufacturer with no config renders as a flat list +// (see ./index.ts). +// +// Collapsibility is per node and defaults to `true`, so only the exceptions +// need stating: the length bands set `collapsible: false` to render as static +// dividers, and every other node omits it and stays a foldable header. + +export interface DisplayCategory { + label: string; + // true (default) => a collapsible group header; false => a static, always-open + // divider. + collapsible?: boolean; + // Free-text rationale for a non-obvious placement. Documentation only, since + // JSON has no comments; not rendered. + note?: string; + children?: DisplayCategory[]; + probes?: string[]; // model ids, in display order +} + +export interface HierarchyConfig { + hierarchy: DisplayCategory[]; +} + +// One node of the resolved tree the walker returns, with manifest entries +// attached and `collapsible` resolved to a concrete boolean. A node has either +// `children` (sub-divided) or `entries` (a leaf), never both. +export interface GroupNode { + label: string; + collapsible: boolean; + count: number; + children?: GroupNode[]; + entries?: ManifestEntry[]; +} diff --git a/apps/probe-viewer/src/state/useAppStore.ts b/apps/probe-viewer/src/state/useAppStore.ts index 0a37206..f4b93a9 100644 --- a/apps/probe-viewer/src/state/useAppStore.ts +++ b/apps/probe-viewer/src/state/useAppStore.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; import { fetchManifest } from "../services/manifest"; import { fetchProbeData } from "../services/probeLoader"; -import type { ManifestEntry, ProbeInterfaceFile } from "../types/probe"; +import type { ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera } from "../types/probe"; type LoadStatus = "idle" | "loading" | "success" | "error"; @@ -12,9 +12,7 @@ interface ProbeLoadState { } interface ViewState { - zoom: number; - viewCenterX: number | null; // null = centered on geometry center - viewCenterY: number | null; // in probe coordinates (micrometers) + camera: ProbeViewerCamera; showContactIds: boolean; showScaleBar: boolean; showOverview: boolean; @@ -30,6 +28,10 @@ interface AppState { probeCache: Record; probeStatus: Record; view: ViewState; + // false until the camera has been seeded from the URL on load (or there was + // nothing to seed). The URL writer holds off until this flips, so it cannot + // clobber a shared link with the default camera at mount. + cameraInitialized: boolean; loadManifest: () => Promise; selectManufacturer: (manufacturer?: string) => void; @@ -38,6 +40,7 @@ interface AppState { ensureProbeLoaded: (probeId: string) => Promise; setZoom: (zoom: number) => void; setViewCenter: (x: number | null, y: number | null) => void; + markCameraInitialized: () => void; resetView: () => void; toggleContactIds: (value?: boolean) => void; toggleScaleBar: (value?: boolean) => void; @@ -47,10 +50,14 @@ interface AppState { export const VIEW_ZOOM_MIN = 0.1; export const VIEW_ZOOM_MAX = 100; // High max for long probes like Neuropixels -const INITIAL_VIEW_STATE: ViewState = { +const INITIAL_CAMERA: ProbeViewerCamera = { zoom: 1, - viewCenterX: null, - viewCenterY: null, + centerX: null, + centerY: null, +}; + +const INITIAL_VIEW_STATE: ViewState = { + camera: INITIAL_CAMERA, showContactIds: false, showScaleBar: true, showOverview: true, @@ -70,6 +77,7 @@ export const useAppStore = create((set, get) => ({ probeCache: {}, probeStatus: {}, view: INITIAL_VIEW_STATE, + cameraInitialized: false, loadManifest: async () => { const { manifestStatus } = get(); @@ -171,7 +179,10 @@ export const useAppStore = create((set, get) => ({ set((state) => ({ view: { ...state.view, - zoom: clamp(zoom, VIEW_ZOOM_MIN, VIEW_ZOOM_MAX), + camera: { + ...state.view.camera, + zoom: clamp(zoom, VIEW_ZOOM_MIN, VIEW_ZOOM_MAX), + }, }, })), @@ -179,11 +190,12 @@ export const useAppStore = create((set, get) => ({ set((state) => ({ view: { ...state.view, - viewCenterX: x, - viewCenterY: y, + camera: { ...state.view.camera, centerX: x, centerY: y }, }, })), + markCameraInitialized: () => set({ cameraInitialized: true }), + resetView: () => set((state) => ({ view: { diff --git a/apps/probe-viewer/src/state/useProbeRouteSync.ts b/apps/probe-viewer/src/state/useProbeRouteSync.ts new file mode 100644 index 0000000..fd46537 --- /dev/null +++ b/apps/probe-viewer/src/state/useProbeRouteSync.ts @@ -0,0 +1,112 @@ +import { useEffect, useMemo } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; + +import { useAppStore } from "./useAppStore"; + +const DEFAULT_PROBE_ID = "plexon:8S1024"; + +// Keeps the selected probe and the URL path (/probes/:manufacturer/:model) in +// agreement: +// +// select (path -> store) on load / manifest change, picks the probe named in +// the URL, falling back to a default +// sync (store -> path) navigates to match the selection when it changes +// +// Unlike the camera sync there is no shared flag: each effect carries its own +// loop-breaker (select no-ops when the selection is already valid; sync skips +// navigation when the path already matches), so the two cannot ping-pong. +export function useProbeRouteSync() { + const { manufacturer, model } = useParams(); + const location = useLocation(); + const navigate = useNavigate(); + + const manifestStatus = useAppStore((state) => state.manifestStatus); + const manifest = useAppStore((state) => state.manifest); + const selectedProbeId = useAppStore((state) => state.selectedProbeId); + const selectProbe = useAppStore((state) => state.selectProbe); + + const manifestById = useMemo(() => { + const map = new Map(); + manifest.forEach((entry) => map.set(entry.id, entry)); + return map; + }, [manifest]); + + // select: URL path -> store + useEffect(() => { + if (manifestStatus !== "success" || manifest.length === 0) { + return; + } + + const routeId = + manufacturer && model ? `${manufacturer}:${model}` : undefined; + const routeEntry = routeId ? manifestById.get(routeId) : undefined; + const currentSelected = selectedProbeId + ? manifestById.get(selectedProbeId) + : undefined; + + const getDefaultProbe = () => + manifestById.get(DEFAULT_PROBE_ID) ?? manifest[0]; + + if (selectedProbeId && !currentSelected) { + const fallback = routeEntry ?? getDefaultProbe(); + if (fallback && fallback.id !== selectedProbeId) { + selectProbe(fallback.id); + } + return; + } + + if (!selectedProbeId) { + if (routeEntry) { + selectProbe(routeEntry.id); + } else { + const fallback = getDefaultProbe(); + if (fallback) { + selectProbe(fallback.id); + } + } + } + }, [ + manifestStatus, + manifest, + manifestById, + manufacturer, + model, + selectedProbeId, + selectProbe, + ]); + + // sync: store -> URL path + useEffect(() => { + if ( + manifestStatus !== "success" || + !selectedProbeId || + manifest.length === 0 + ) { + return; + } + + const selectedEntry = manifestById.get(selectedProbeId); + if (!selectedEntry) { + return; + } + + const routeId = + manufacturer && model ? `${manufacturer}:${model}` : undefined; + if (routeId === selectedEntry.id) { + return; + } + + const targetPath = `/probes/${selectedEntry.manufacturer}/${selectedEntry.model}`; + const replace = location.pathname === "/"; + navigate(targetPath, { replace }); + }, [ + manifestStatus, + selectedProbeId, + manifestById, + manufacturer, + model, + navigate, + location.pathname, + manifest.length, + ]); +} diff --git a/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts b/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts new file mode 100644 index 0000000..3113ed8 --- /dev/null +++ b/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts @@ -0,0 +1,46 @@ +import { useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { useAppStore } from "./useAppStore"; + +// Reads the camera params (zoom/cx/cy) from a shared link into the store. +function restoreCameraFromParams( + searchParams: URLSearchParams, + setZoom: (zoom: number) => void, + setViewCenter: (x: number | null, y: number | null) => void, +) { + const zoomParam = searchParams.get("zoom"); + const cxParam = searchParams.get("cx"); + const cyParam = searchParams.get("cy"); + + if (zoomParam) { + const zoom = parseFloat(zoomParam); + if (!isNaN(zoom)) setZoom(zoom); + } + // A center needs both coordinates. If either is missing from the URL + // (e.g. a link that only set zoom), there is no center to restore. + const cx = cxParam ? parseFloat(cxParam) : NaN; + const cy = cyParam ? parseFloat(cyParam) : NaN; + const hasCenter = !isNaN(cx) && !isNaN(cy); + if (hasCenter) { + setViewCenter(cx, cy); + } +} + +// restore: URL -> store, once per page load. Applies a shared link's camera on +// mount, then flips `cameraInitialized` so the URL writer is allowed to start. +// The restore-before-write ordering this guarantees is documented on +// `cameraInitialized` in the store; useSyncCameraToUrl is the other half. +export function useRestoreCameraFromUrl() { + const [searchParams] = useSearchParams(); + const cameraInitialized = useAppStore((state) => state.cameraInitialized); + const setZoom = useAppStore((state) => state.setZoom); + const setViewCenter = useAppStore((state) => state.setViewCenter); + const markCameraInitialized = useAppStore((state) => state.markCameraInitialized); + + useEffect(() => { + if (cameraInitialized) return; + restoreCameraFromParams(searchParams, setZoom, setViewCenter); + markCameraInitialized(); + }, [cameraInitialized, searchParams, setZoom, setViewCenter, markCameraInitialized]); +} diff --git a/apps/probe-viewer/src/state/useSyncCameraToUrl.ts b/apps/probe-viewer/src/state/useSyncCameraToUrl.ts new file mode 100644 index 0000000..c513997 --- /dev/null +++ b/apps/probe-viewer/src/state/useSyncCameraToUrl.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef } from "react"; +import { useSearchParams } from "react-router-dom"; +import type { SetURLSearchParams } from "react-router-dom"; + +import { useAppStore } from "./useAppStore"; +import type { ProbeViewerCamera } from "../types/probe"; + +// Coordinates are rounded before going into the URL so shared links stay short +// and stable (e.g. 2.5 / 100.3 instead of 2.4999999 / 100.34871). +function roundForUrl(value: number, decimals = 1): number { + const factor = Math.pow(10, decimals); + return Math.round(value * factor) / factor; +} + +// Writes the current camera into the query string, dropping the params entirely +// when the camera is back at its default (zoom 1, no center). +function writeCameraToParams( + camera: ProbeViewerCamera, + setSearchParams: SetURLSearchParams, +) { + const { zoom, centerX, centerY } = camera; + const isDefault = zoom === 1 && centerX === null && centerY === null; + + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (isDefault) { + next.delete("zoom"); + next.delete("cx"); + next.delete("cy"); + } else { + next.set("zoom", String(roundForUrl(zoom, 2))); + if (centerX !== null && centerY !== null) { + next.set("cx", String(roundForUrl(centerX, 1))); + next.set("cy", String(roundForUrl(centerY, 1))); + } else { + next.delete("cx"); + next.delete("cy"); + } + } + return next; + }, + { replace: true }, + ); +} + +// sync: store -> URL, debounced. Writes the current camera into the query string +// on every zoom/pan, but only once `cameraInitialized` is set, so it can't wipe +// a shared link's params before useRestoreCameraFromUrl has read them. The +// restore-before-write ordering is documented on `cameraInitialized` in the store. +export function useSyncCameraToUrl() { + const [, setSearchParams] = useSearchParams(); + const camera = useAppStore((state) => state.view.camera); + const cameraInitialized = useAppStore((state) => state.cameraInitialized); + + const writeTimeout = useRef | undefined>(undefined); + useEffect(() => { + if (!cameraInitialized) return; + + clearTimeout(writeTimeout.current); + writeTimeout.current = setTimeout(() => { + writeCameraToParams(camera, setSearchParams); + }, 300); + + return () => clearTimeout(writeTimeout.current); + }, [cameraInitialized, camera, setSearchParams]); +} diff --git a/apps/probe-viewer/src/types/probe.ts b/apps/probe-viewer/src/types/probe.ts index efe6033..68f6158 100644 --- a/apps/probe-viewer/src/types/probe.ts +++ b/apps/probe-viewer/src/types/probe.ts @@ -28,6 +28,15 @@ export interface ContactShapeParams { height?: number; // for rect } +// The spatial view over the probe: where the camera sits and how magnified. +// Plain data (no methods) so it stays referentially diff-able by the store and +// trivially serializable to the URL query string. +export interface ProbeViewerCamera { + zoom: number; + centerX: number | null; // null = centered on geometry center + centerY: number | null; // in probe coordinates (micrometers) +} + export interface ProbeInterfaceProbe { ndim: number; si_units: string; diff --git a/apps/probe-viewer/src/utils/exportUtils.ts b/apps/probe-viewer/src/utils/exportUtils.ts index d071d4f..d5f94fe 100644 --- a/apps/probe-viewer/src/utils/exportUtils.ts +++ b/apps/probe-viewer/src/utils/exportUtils.ts @@ -1,10 +1,4 @@ -import type { ProbeInterfaceFile, ContactShapeParams } from "../types/probe"; - -interface ExportViewState { - zoom: number; - viewCenterX: number | null; - viewCenterY: number | null; -} +import type { ProbeInterfaceFile, ContactShapeParams, ProbeViewerCamera } from "../types/probe"; interface CanvasSize { width: number; @@ -63,7 +57,7 @@ function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary */ export function exportProbeAsPng( probeData: ProbeInterfaceFile, - viewState: ExportViewState, + camera: ProbeViewerCamera, canvasSize: CanvasSize, filename: string, showScaleBar: boolean @@ -83,7 +77,7 @@ export function exportProbeAsPng( ctx.fillRect(0, 0, canvasSize.width, canvasSize.height); // Render probe (no contact IDs, scale bar if enabled) - renderProbeToContext(ctx, probeData, viewState, canvasSize, showScaleBar); + renderProbeToContext(ctx, probeData, camera, canvasSize, showScaleBar); // Download const link = document.createElement("a"); @@ -98,12 +92,12 @@ export function exportProbeAsPng( */ export function exportProbeAsSvg( probeData: ProbeInterfaceFile, - viewState: ExportViewState, + camera: ProbeViewerCamera, canvasSize: CanvasSize, filename: string, showScaleBar: boolean ): void { - const svgString = generateProbeSvgString(probeData, viewState, canvasSize, showScaleBar); + const svgString = generateProbeSvgString(probeData, camera, canvasSize, showScaleBar); const blob = new Blob([svgString], { type: "image/svg+xml" }); const link = document.createElement("a"); link.download = filename; @@ -119,7 +113,7 @@ export function exportProbeAsSvg( function renderProbeToContext( ctx: CanvasRenderingContext2D, probeData: ProbeInterfaceFile, - viewState: ExportViewState, + camera: ProbeViewerCamera, canvasSize: CanvasSize, showScaleBar: boolean ): void { @@ -127,12 +121,12 @@ function renderProbeToContext( const probe = probeData.probes?.[0]; if (!geometry || !probe) return; - const { zoom, viewCenterX, viewCenterY } = viewState; + const { zoom, centerX, centerY } = camera; const { width: widthPx, height: heightPx } = canvasSize; // Calculate effective view center (use geometry center if null) - const effectiveViewCenterX = viewCenterX ?? geometry.centerX; - const effectiveViewCenterY = viewCenterY ?? geometry.centerY; + const effectiveViewCenterX = centerX ?? geometry.centerX; + const effectiveViewCenterY = centerY ?? geometry.centerY; const padding = 40; const availableWidth = Math.max(10, widthPx - padding * 2); @@ -310,7 +304,7 @@ function renderProbeToContext( */ function generateProbeSvgString( probeData: ProbeInterfaceFile, - viewState: ExportViewState, + camera: ProbeViewerCamera, canvasSize: CanvasSize, showScaleBar: boolean ): string { @@ -321,12 +315,12 @@ function generateProbeSvgString( return ``; } - const { zoom, viewCenterX, viewCenterY } = viewState; + const { zoom, centerX, centerY } = camera; const { width: widthPx, height: heightPx } = canvasSize; // Calculate effective view center (use geometry center if null) - const effectiveViewCenterX = viewCenterX ?? geometry.centerX; - const effectiveViewCenterY = viewCenterY ?? geometry.centerY; + const effectiveViewCenterX = centerX ?? geometry.centerX; + const effectiveViewCenterY = centerY ?? geometry.centerY; const padding = 40; const availableWidth = Math.max(10, widthPx - padding * 2); diff --git a/apps/probe-viewer/tsconfig.app.json b/apps/probe-viewer/tsconfig.app.json index a9b5a59..412074e 100644 --- a/apps/probe-viewer/tsconfig.app.json +++ b/apps/probe-viewer/tsconfig.app.json @@ -7,6 +7,7 @@ "module": "ESNext", "types": ["vite/client"], "skipLibCheck": true, + "resolveJsonModule": true, /* Bundler mode */ "moduleResolution": "bundler",