From 8bf0a89514589285d631fe8264283448fb635af1 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 8 Jun 2026 16:51:37 -0600 Subject: [PATCH 1/9] Neuropixel grouping --- .gitignore | 1 + apps/probe-viewer/src/App.css | 99 +++++++++++ apps/probe-viewer/src/components/Sidebar.tsx | 142 ++++++++++++++-- .../src/utils/neuropixelsGrouping.ts | 160 ++++++++++++++++++ 4 files changed, 384 insertions(+), 18 deletions(-) create mode 100644 apps/probe-viewer/src/utils/neuropixelsGrouping.ts 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..504765b 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -128,6 +128,105 @@ color: #475569; } +.sidebar-item-variant { + font-weight: 400; + color: #64748b; + font-size: 0.85rem; +} + +/* 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/components/Sidebar.tsx b/apps/probe-viewer/src/components/Sidebar.tsx index 5a3477a..f73a0e0 100644 --- a/apps/probe-viewer/src/components/Sidebar.tsx +++ b/apps/probe-viewer/src/components/Sidebar.tsx @@ -1,6 +1,8 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useAppStore } from "../state/useAppStore"; +import type { ManifestEntry } from "../types/probe"; +import { groupNeuropixels, variantLabel } from "../utils/neuropixelsGrouping"; const MANUFACTURER_DISPLAY_NAMES: Record = { cambridgeneurotech: "Cambridge NeuroTech", @@ -59,6 +61,60 @@ export function Sidebar() { } }, [filteredEntries, selectedProbeId, selectProbe]); + // Neuropixels (imec) gets a hierarchy-grouped list (platform -> family); + // every other manufacturer keeps the simple flat list. + const isNeuropixels = selectedManufacturer === "imec"; + const groups = useMemo( + () => (isNeuropixels ? groupNeuropixels(filteredEntries) : []), + [isNeuropixels, filteredEntries], + ); + + // Both levels start collapsed (empty expanded sets). A platform shows its + // families only when expanded; a family shows its probes only when expanded. + const [expandedPlatforms, setExpandedPlatforms] = useState>( + () => new Set(), + ); + const [expandedFamilies, setExpandedFamilies] = useState>( + () => new Set(), + ); + const togglePlatform = (platform: string) => + setExpandedPlatforms((prev) => { + const next = new Set(prev); + if (next.has(platform)) next.delete(platform); + else next.add(platform); + return next; + }); + const toggleFamily = (key: string) => + setExpandedFamilies((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + const renderItem = (entry: ManifestEntry, showVariant: boolean) => ( + + ); + return (
@@ -110,23 +166,73 @@ export function Sidebar() { {manifestStatus === "success" && filteredEntries.length === 0 && (

No probes match the current filters.

)} - {filteredEntries.map((entry) => ( - - ))} + + {manifestStatus === "success" && + !isNeuropixels && + filteredEntries.map((entry) => renderItem(entry, false))} + + {manifestStatus === "success" && + isNeuropixels && + groups.map((group) => { + const platformOpen = expandedPlatforms.has(group.platform); + return ( +
+ + {platformOpen && + group.families.map((fam) => { + const familyKey = `${group.platform}||${fam.family}`; + const familyOpen = expandedFamilies.has(familyKey); + return ( +
+ + {familyOpen && + fam.subgroups.map((sub) => ( +
+ {sub.label && ( +

+ {sub.label} +

+ )} + {sub.entries.map((entry) => + renderItem(entry, true), + )} +
+ ))} +
+ ); + })} +
+ ); + })}
); diff --git a/apps/probe-viewer/src/utils/neuropixelsGrouping.ts b/apps/probe-viewer/src/utils/neuropixelsGrouping.ts new file mode 100644 index 0000000..cd7efcd --- /dev/null +++ b/apps/probe-viewer/src/utils/neuropixelsGrouping.ts @@ -0,0 +1,160 @@ +import type { ManifestEntry } from "../types/probe"; + +// Groups the flat Neuropixels (imec) probe list into the hierarchy a user +// actually reasons about: platform (electronics generation) -> family (physical +// design). This is a frontend-only, best-effort derivation: platform comes from +// the part-number prefix, family from the probe's free-text description. It is a +// prototype heuristic; the authoritative version would have probeinterface emit +// explicit platform/family fields into the probe JSON. + +export interface ProbeSubGroup { + // A static (non-collapsible) divider within a family. An empty label means + // the family is rendered as a flat list with no divider. + label: string; + entries: ManifestEntry[]; +} + +export interface ProbeFamilyGroup { + family: string; + entries: ManifestEntry[]; + subgroups: ProbeSubGroup[]; +} + +export interface ProbePlatformGroup { + platform: string; + count: number; + families: ProbeFamilyGroup[]; +} + +const PLATFORM_ORDER = [ + "Neuropixels 1.0", + "Neuropixels 2.0", + "Neuropixels NXT", + "Other", +]; + +const FAMILY_ORDER = [ + "Standard", + "Non-human-primate", + "Ultra High Density", + "Optogenetics", + "Single-shank", + "Multi-shank", + "Passive", + "Legacy", + "Other", +]; + +// Length variants shown as static sub-dividers inside the Non-human-primate +// family. Length (short ~10 mm, medium ~25 mm, long/max ~45 mm) is the key +// differentiator for these probes, so it is preserved as a divider rather than +// flattened away. +const NHP_SUB_ORDER = ["short", "medium", "long / max", "passive", "other"]; + +function nhpLengthDivider(description: string): string { + const d = description.toLowerCase(); + if (d.includes("passive")) return "passive"; + if (d.includes("short")) return "short"; + if (d.includes("medium")) return "medium"; + if (d.includes("long") || d.includes("max")) return "long / max"; + return "other"; +} + +export function platformOf(entry: ManifestEntry): string { + const model = entry.model; + // Legacy PRB_* SKUs are the same Neuropixels 1.0 / 2.0 probes under the older + // naming scheme, so they belong to those platforms (as a "Legacy" family), + // not to a platform of their own. + if (/^PRB2/i.test(model)) return "Neuropixels 2.0"; + if (/^PRB_?1/i.test(model)) return "Neuropixels 1.0"; + if (/^NP1/.test(model)) return "Neuropixels 1.0"; + if (/^NP2/.test(model)) return "Neuropixels 2.0"; + if (/^NP3/.test(model)) return "Neuropixels NXT"; + return "Other"; +} + +function descriptionOf(entry: ManifestEntry): string { + const raw = (entry.annotations as Record)?.description; + return typeof raw === "string" ? raw.toLowerCase() : ""; +} + +export function familyOf(entry: ManifestEntry): string { + if (/^PRB/i.test(entry.model)) return "Legacy"; + const d = descriptionOf(entry); + if (d.includes("uhd")) return "Ultra High Density"; + if (d.includes("opto")) return "Optogenetics"; + if (d.includes("nhp")) return "Non-human-primate"; + if (d.includes("passive")) return "Passive"; + if (entry.shankCount >= 4) return "Multi-shank"; + if (platformOf(entry).startsWith("Neuropixels 1.0")) return "Standard"; + if (d.includes("single") || entry.shankCount === 1) return "Single-shank"; + return "Other"; +} + +// Short, human label for what distinguishes one variant from its siblings: +// the tail of the description after the platform/family boilerplate. Falls back +// to the full description when nothing meaningful is left. +export function variantLabel(entry: ManifestEntry): string { + const raw = (entry.annotations as Record)?.description; + const full = typeof raw === "string" ? raw : ""; + const trimmed = full + .replace(/^(neuropixels|npix)\s*[0-9.]*\s*/i, "") + .replace(/\bnhp\b/gi, "") + .replace(/\bprobe\b/gi, "") + .replace(/\b(short|medium|long|max|uhd\d*|opto\w*|passive|multi[\s-]?shank|single[\s-]?shank)\b/gi, "") + .replace(/\s{2,}/g, " ") + .replace(/^[\s,]+|[\s,]+$/g, "") + .trim(); + return trimmed.length > 1 ? trimmed : full; +} + +export function groupNeuropixels(entries: ManifestEntry[]): ProbePlatformGroup[] { + const byPlatform = new Map>(); + for (const entry of entries) { + const platform = platformOf(entry); + const family = familyOf(entry); + if (!byPlatform.has(platform)) byPlatform.set(platform, new Map()); + const families = byPlatform.get(platform)!; + if (!families.has(family)) families.set(family, []); + families.get(family)!.push(entry); + } + + const platformRank = (p: string) => { + const index = PLATFORM_ORDER.indexOf(p); + return index === -1 ? PLATFORM_ORDER.length : index; + }; + const familyRank = (f: string) => { + const index = FAMILY_ORDER.indexOf(f); + return index === -1 ? FAMILY_ORDER.length : index; + }; + + const groups: ProbePlatformGroup[] = []; + for (const [platform, familyMap] of byPlatform) { + const families = [...familyMap.entries()] + .sort((a, b) => familyRank(a[0]) - familyRank(b[0])) + .map(([family, list]) => { + const entries = list.sort((a, b) => a.model.localeCompare(b.model)); + let subgroups: ProbeSubGroup[]; + if (family === "Non-human-primate") { + const byLength = new Map(); + for (const entry of entries) { + const divider = nhpLengthDivider(descriptionOf(entry)); + if (!byLength.has(divider)) byLength.set(divider, []); + byLength.get(divider)!.push(entry); + } + subgroups = [...byLength.entries()] + .sort( + (a, b) => + NHP_SUB_ORDER.indexOf(a[0]) - NHP_SUB_ORDER.indexOf(b[0]), + ) + .map(([label, items]) => ({ label, entries: items })); + } else { + subgroups = [{ label: "", entries }]; + } + return { family, entries, subgroups }; + }); + const count = families.reduce((sum, f) => sum + f.entries.length, 0); + groups.push({ platform, count, families }); + } + return groups.sort((a, b) => platformRank(a.platform) - platformRank(b.platform)); +} From 1baaf1678e8741a9111d363ef0c13515c67ec11c Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 8 Jun 2026 17:09:52 -0600 Subject: [PATCH 2/9] searching unfolds menus --- apps/probe-viewer/src/components/Sidebar.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/probe-viewer/src/components/Sidebar.tsx b/apps/probe-viewer/src/components/Sidebar.tsx index f73a0e0..cce5c48 100644 --- a/apps/probe-viewer/src/components/Sidebar.tsx +++ b/apps/probe-viewer/src/components/Sidebar.tsx @@ -69,6 +69,11 @@ export function Sidebar() { [isNeuropixels, filteredEntries], ); + // While searching, force every group open so matches aren't hidden inside a + // collapsed group (filteredEntries already contains only the hits). When the + // search clears, the user's manual expand/collapse state takes over again. + const isSearching = searchQuery.trim().length > 0; + // Both levels start collapsed (empty expanded sets). A platform shows its // families only when expanded; a family shows its probes only when expanded. const [expandedPlatforms, setExpandedPlatforms] = useState>( @@ -174,7 +179,7 @@ export function Sidebar() { {manifestStatus === "success" && isNeuropixels && groups.map((group) => { - const platformOpen = expandedPlatforms.has(group.platform); + const platformOpen = isSearching || expandedPlatforms.has(group.platform); return (
); + // 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 (
@@ -173,71 +206,12 @@ export function Sidebar() { )} {manifestStatus === "success" && - !isNeuropixels && - filteredEntries.map((entry) => renderItem(entry, false))} + !groups && + filteredEntries.map((entry) => renderItem(entry))} {manifestStatus === "success" && - isNeuropixels && - groups.map((group) => { - const platformOpen = isSearching || expandedPlatforms.has(group.platform); - return ( -
- - {platformOpen && - group.families.map((fam) => { - const familyKey = `${group.platform}||${fam.family}`; - const familyOpen = isSearching || expandedFamilies.has(familyKey); - return ( -
- - {familyOpen && - fam.subgroups.map((sub) => ( -
- {sub.label && ( -

- {sub.label} -

- )} - {sub.entries.map((entry) => - renderItem(entry, true), - )} -
- ))} -
- ); - })} -
- ); - })} + groups && + groups.map((node) => renderNode(node, 0, []))}
); diff --git a/apps/probe-viewer/src/utils/neuropixelsGrouping.ts b/apps/probe-viewer/src/utils/neuropixelsGrouping.ts deleted file mode 100644 index cd7efcd..0000000 --- a/apps/probe-viewer/src/utils/neuropixelsGrouping.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { ManifestEntry } from "../types/probe"; - -// Groups the flat Neuropixels (imec) probe list into the hierarchy a user -// actually reasons about: platform (electronics generation) -> family (physical -// design). This is a frontend-only, best-effort derivation: platform comes from -// the part-number prefix, family from the probe's free-text description. It is a -// prototype heuristic; the authoritative version would have probeinterface emit -// explicit platform/family fields into the probe JSON. - -export interface ProbeSubGroup { - // A static (non-collapsible) divider within a family. An empty label means - // the family is rendered as a flat list with no divider. - label: string; - entries: ManifestEntry[]; -} - -export interface ProbeFamilyGroup { - family: string; - entries: ManifestEntry[]; - subgroups: ProbeSubGroup[]; -} - -export interface ProbePlatformGroup { - platform: string; - count: number; - families: ProbeFamilyGroup[]; -} - -const PLATFORM_ORDER = [ - "Neuropixels 1.0", - "Neuropixels 2.0", - "Neuropixels NXT", - "Other", -]; - -const FAMILY_ORDER = [ - "Standard", - "Non-human-primate", - "Ultra High Density", - "Optogenetics", - "Single-shank", - "Multi-shank", - "Passive", - "Legacy", - "Other", -]; - -// Length variants shown as static sub-dividers inside the Non-human-primate -// family. Length (short ~10 mm, medium ~25 mm, long/max ~45 mm) is the key -// differentiator for these probes, so it is preserved as a divider rather than -// flattened away. -const NHP_SUB_ORDER = ["short", "medium", "long / max", "passive", "other"]; - -function nhpLengthDivider(description: string): string { - const d = description.toLowerCase(); - if (d.includes("passive")) return "passive"; - if (d.includes("short")) return "short"; - if (d.includes("medium")) return "medium"; - if (d.includes("long") || d.includes("max")) return "long / max"; - return "other"; -} - -export function platformOf(entry: ManifestEntry): string { - const model = entry.model; - // Legacy PRB_* SKUs are the same Neuropixels 1.0 / 2.0 probes under the older - // naming scheme, so they belong to those platforms (as a "Legacy" family), - // not to a platform of their own. - if (/^PRB2/i.test(model)) return "Neuropixels 2.0"; - if (/^PRB_?1/i.test(model)) return "Neuropixels 1.0"; - if (/^NP1/.test(model)) return "Neuropixels 1.0"; - if (/^NP2/.test(model)) return "Neuropixels 2.0"; - if (/^NP3/.test(model)) return "Neuropixels NXT"; - return "Other"; -} - -function descriptionOf(entry: ManifestEntry): string { - const raw = (entry.annotations as Record)?.description; - return typeof raw === "string" ? raw.toLowerCase() : ""; -} - -export function familyOf(entry: ManifestEntry): string { - if (/^PRB/i.test(entry.model)) return "Legacy"; - const d = descriptionOf(entry); - if (d.includes("uhd")) return "Ultra High Density"; - if (d.includes("opto")) return "Optogenetics"; - if (d.includes("nhp")) return "Non-human-primate"; - if (d.includes("passive")) return "Passive"; - if (entry.shankCount >= 4) return "Multi-shank"; - if (platformOf(entry).startsWith("Neuropixels 1.0")) return "Standard"; - if (d.includes("single") || entry.shankCount === 1) return "Single-shank"; - return "Other"; -} - -// Short, human label for what distinguishes one variant from its siblings: -// the tail of the description after the platform/family boilerplate. Falls back -// to the full description when nothing meaningful is left. -export function variantLabel(entry: ManifestEntry): string { - const raw = (entry.annotations as Record)?.description; - const full = typeof raw === "string" ? raw : ""; - const trimmed = full - .replace(/^(neuropixels|npix)\s*[0-9.]*\s*/i, "") - .replace(/\bnhp\b/gi, "") - .replace(/\bprobe\b/gi, "") - .replace(/\b(short|medium|long|max|uhd\d*|opto\w*|passive|multi[\s-]?shank|single[\s-]?shank)\b/gi, "") - .replace(/\s{2,}/g, " ") - .replace(/^[\s,]+|[\s,]+$/g, "") - .trim(); - return trimmed.length > 1 ? trimmed : full; -} - -export function groupNeuropixels(entries: ManifestEntry[]): ProbePlatformGroup[] { - const byPlatform = new Map>(); - for (const entry of entries) { - const platform = platformOf(entry); - const family = familyOf(entry); - if (!byPlatform.has(platform)) byPlatform.set(platform, new Map()); - const families = byPlatform.get(platform)!; - if (!families.has(family)) families.set(family, []); - families.get(family)!.push(entry); - } - - const platformRank = (p: string) => { - const index = PLATFORM_ORDER.indexOf(p); - return index === -1 ? PLATFORM_ORDER.length : index; - }; - const familyRank = (f: string) => { - const index = FAMILY_ORDER.indexOf(f); - return index === -1 ? FAMILY_ORDER.length : index; - }; - - const groups: ProbePlatformGroup[] = []; - for (const [platform, familyMap] of byPlatform) { - const families = [...familyMap.entries()] - .sort((a, b) => familyRank(a[0]) - familyRank(b[0])) - .map(([family, list]) => { - const entries = list.sort((a, b) => a.model.localeCompare(b.model)); - let subgroups: ProbeSubGroup[]; - if (family === "Non-human-primate") { - const byLength = new Map(); - for (const entry of entries) { - const divider = nhpLengthDivider(descriptionOf(entry)); - if (!byLength.has(divider)) byLength.set(divider, []); - byLength.get(divider)!.push(entry); - } - subgroups = [...byLength.entries()] - .sort( - (a, b) => - NHP_SUB_ORDER.indexOf(a[0]) - NHP_SUB_ORDER.indexOf(b[0]), - ) - .map(([label, items]) => ({ label, entries: items })); - } else { - subgroups = [{ label: "", entries }]; - } - return { family, entries, subgroups }; - }); - const count = families.reduce((sum, f) => sum + f.entries.length, 0); - groups.push({ platform, count, families }); - } - return groups.sort((a, b) => platformRank(a.platform) - platformRank(b.platform)); -} 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", From b5dea7a614c0262a4e27b4157b0d0f485a2ebc06 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 9 Jun 2026 12:00:37 -0600 Subject: [PATCH 4/9] add untracked files --- apps/probe-viewer/src/grouping/README.md | 39 +++++++++++ .../probe-viewer/src/grouping/groupEntries.ts | 64 +++++++++++++++++++ .../src/grouping/imec_neuropixels.json | 34 ++++++++++ apps/probe-viewer/src/grouping/index.ts | 17 +++++ apps/probe-viewer/src/grouping/types.ts | 37 +++++++++++ 5 files changed, 191 insertions(+) create mode 100644 apps/probe-viewer/src/grouping/README.md create mode 100644 apps/probe-viewer/src/grouping/groupEntries.ts create mode 100644 apps/probe-viewer/src/grouping/imec_neuropixels.json create mode 100644 apps/probe-viewer/src/grouping/index.ts create mode 100644 apps/probe-viewer/src/grouping/types.ts diff --git a/apps/probe-viewer/src/grouping/README.md b/apps/probe-viewer/src/grouping/README.md new file mode 100644 index 0000000..fad3c6b --- /dev/null +++ b/apps/probe-viewer/src/grouping/README.md @@ -0,0 +1,39 @@ +# Sidebar grouping + +The probe sidebar turns a manufacturer's flat probe list into a hierarchy from +an explicit, hand-curated config. 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. + +This replaced an earlier rule-based engine that derived the grouping by regex on +the part number and substring-matching the free-text description. Those rules +were fragile because the probe metadata does not carry the facts we group on +(generation, family, length), so we were reconstructing editorial decisions from +strings. The explicit file states those decisions directly instead. + +## 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 **propagates to descendants**: a node +uses its own `collapsible` when set, otherwise it inherits the resolved value of +its parent, and the top-level default is `true`. So each platform sets +`collapsible: true` and that flows down to its families, while each length band +sets `collapsible: false` (a static, always-open divider) and that flows down to +its probes. `true` is a foldable header with a caret; `false` is a static +divider. + +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 here or in a `note` field on the +node. diff --git a/apps/probe-viewer/src/grouping/groupEntries.ts b/apps/probe-viewer/src/grouping/groupEntries.ts new file mode 100644 index 0000000..342070b --- /dev/null +++ b/apps/probe-viewer/src/grouping/groupEntries.ts @@ -0,0 +1,64 @@ +import type { ManifestEntry } from "../types/probe"; +import type { GroupNode, HierarchyConfig, HierarchyNode } 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"; + +// Top-level default when a node does not set `collapsible` and has no parent. +const ROOT_COLLAPSIBLE = true; + +// 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 resolved per node, inheriting the parent's value when +// the node does not set its own. +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: HierarchyNode, inherited: boolean): GroupNode | null => { + const collapsible = node.collapsible ?? inherited; + + if (node.children) { + const children = node.children + .map((child) => walk(child, collapsible)) + .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, ROOT_COLLAPSIBLE)) + .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..aa4f430 --- /dev/null +++ b/apps/probe-viewer/src/grouping/imec_neuropixels.json @@ -0,0 +1,34 @@ +{ + "hierarchy": [ + { "label": "Neuropixels 1.0", "collapsible": true, + "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", "collapsible": true, + "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", "collapsible": true, + "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..b242d0c --- /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 a per-node property that propagates to descendants: a node +// uses its own `collapsible` when set, otherwise it inherits the resolved value +// of its parent. The top-level default is `true`. So a platform marked +// collapsible flows that down to its families, and a length band marked +// non-collapsible flows that down to its probes, without either having to be +// repeated on every node. + +export interface HierarchyNode { + label: string; + // true => a collapsible group header; false => a static, always-open divider. + // Omitted => inherit the parent's resolved value (root default: true). + collapsible?: boolean; + children?: HierarchyNode[]; + probes?: string[]; // model ids, in display order +} + +export interface HierarchyConfig { + hierarchy: HierarchyNode[]; +} + +// 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[]; +} From 199ad6dfbc94a8fdf04ec7a765ca68a60e39b50a Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 9 Jun 2026 12:04:22 -0600 Subject: [PATCH 5/9] Remove dead .sidebar-item-variant CSS rule --- apps/probe-viewer/src/App.css | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index 504765b..ff5e061 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -128,12 +128,6 @@ color: #475569; } -.sidebar-item-variant { - font-weight: 400; - color: #64748b; - font-size: 0.85rem; -} - /* Neuropixels hierarchy grouping */ .sidebar-group { display: flex; From f4c92ba053a5e9ddbb2b9d83bda4d3647a94354b Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 9 Jun 2026 12:16:13 -0600 Subject: [PATCH 6/9] collapsible true --- apps/probe-viewer/src/grouping/README.md | 10 +++++----- apps/probe-viewer/src/grouping/imec_neuropixels.json | 6 +++--- apps/probe-viewer/src/grouping/types.ts | 7 +++---- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/apps/probe-viewer/src/grouping/README.md b/apps/probe-viewer/src/grouping/README.md index fad3c6b..3f567bc 100644 --- a/apps/probe-viewer/src/grouping/README.md +++ b/apps/probe-viewer/src/grouping/README.md @@ -20,11 +20,11 @@ order) at the leaves. `collapsible` is a per-node property that **propagates to descendants**: a node uses its own `collapsible` when set, otherwise it inherits the resolved value of -its parent, and the top-level default is `true`. So each platform sets -`collapsible: true` and that flows down to its families, while each length band -sets `collapsible: false` (a static, always-open divider) and that flows down to -its probes. `true` is a foldable header with a caret; `false` is a static -divider. +its parent, and the top-level default is `true`. So only the exceptions need +stating: the length bands set `collapsible: false` (a static, always-open +divider) and that flows down to their probes, while everything else omits it and +inherits the default. `true` is a foldable header with a caret; `false` is a +static divider. The walker (`groupEntries.ts`) attaches each manifest entry to the node listing its model, prunes empty branches, and collects anything not placed into a diff --git a/apps/probe-viewer/src/grouping/imec_neuropixels.json b/apps/probe-viewer/src/grouping/imec_neuropixels.json index aa4f430..dc5137c 100644 --- a/apps/probe-viewer/src/grouping/imec_neuropixels.json +++ b/apps/probe-viewer/src/grouping/imec_neuropixels.json @@ -1,6 +1,6 @@ { "hierarchy": [ - { "label": "Neuropixels 1.0", "collapsible": true, + { "label": "Neuropixels 1.0", "children": [ { "label": "Standard", "probes": ["NP1000", "NP1001"] }, { "label": "Non-human-primate", @@ -14,7 +14,7 @@ { "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", "collapsible": true, + { "label": "Neuropixels 2.0", "children": [ { "label": "Non-human-primate", "children": [ @@ -24,7 +24,7 @@ { "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", "collapsible": true, + { "label": "Neuropixels NXT", "children": [ { "label": "Single-shank", "probes": ["NP3010", "NP3011"] }, { "label": "Multi-shank", "probes": ["NP3020", "NP3021", "NP3022", "NP3023", "NP3024"] }, diff --git a/apps/probe-viewer/src/grouping/types.ts b/apps/probe-viewer/src/grouping/types.ts index b242d0c..2850bd4 100644 --- a/apps/probe-viewer/src/grouping/types.ts +++ b/apps/probe-viewer/src/grouping/types.ts @@ -7,10 +7,9 @@ import type { ManifestEntry } from "../types/probe"; // // Collapsibility is a per-node property that propagates to descendants: a node // uses its own `collapsible` when set, otherwise it inherits the resolved value -// of its parent. The top-level default is `true`. So a platform marked -// collapsible flows that down to its families, and a length band marked -// non-collapsible flows that down to its probes, without either having to be -// repeated on every node. +// of its parent, and the top-level default is `true`. So only the exceptions +// need stating: the length bands set `collapsible: false`, which flows down to +// their probes, while everything else inherits the default and stays foldable. export interface HierarchyNode { label: string; From e4a6c3bdb785949de3147a63c082a5233b271817 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 9 Jun 2026 12:17:51 -0600 Subject: [PATCH 7/9] remoev inheritance --- apps/probe-viewer/src/grouping/README.md | 11 ++++------- apps/probe-viewer/src/grouping/groupEntries.ts | 14 +++++--------- apps/probe-viewer/src/grouping/types.ts | 12 +++++------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/apps/probe-viewer/src/grouping/README.md b/apps/probe-viewer/src/grouping/README.md index 3f567bc..5571cce 100644 --- a/apps/probe-viewer/src/grouping/README.md +++ b/apps/probe-viewer/src/grouping/README.md @@ -18,13 +18,10 @@ 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 **propagates to descendants**: a node -uses its own `collapsible` when set, otherwise it inherits the resolved value of -its parent, and the top-level default is `true`. So only the exceptions need -stating: the length bands set `collapsible: false` (a static, always-open -divider) and that flows down to their probes, while everything else omits it and -inherits the default. `true` is a foldable header with a caret; `false` is a -static divider. +`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. The walker (`groupEntries.ts`) attaches each manifest entry to the node listing its model, prunes empty branches, and collects anything not placed into a diff --git a/apps/probe-viewer/src/grouping/groupEntries.ts b/apps/probe-viewer/src/grouping/groupEntries.ts index 342070b..85ca9b2 100644 --- a/apps/probe-viewer/src/grouping/groupEntries.ts +++ b/apps/probe-viewer/src/grouping/groupEntries.ts @@ -6,14 +6,10 @@ import type { GroupNode, HierarchyConfig, HierarchyNode } from "./types"; // probe needs a home in the config. const UNGROUPED_LABEL = "Ungrouped"; -// Top-level default when a node does not set `collapsible` and has no parent. -const ROOT_COLLAPSIBLE = true; - // 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 resolved per node, inheriting the parent's value when -// the node does not set its own. +// group. `collapsible` is per node and defaults to true. export function groupEntries( entries: ManifestEntry[], config: HierarchyConfig, @@ -22,12 +18,12 @@ export function groupEntries( for (const entry of entries) byModel.set(entry.model, entry); const placed = new Set(); - const walk = (node: HierarchyNode, inherited: boolean): GroupNode | null => { - const collapsible = node.collapsible ?? inherited; + const walk = (node: HierarchyNode): GroupNode | null => { + const collapsible = node.collapsible ?? true; if (node.children) { const children = node.children - .map((child) => walk(child, collapsible)) + .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); @@ -47,7 +43,7 @@ export function groupEntries( }; const groups = config.hierarchy - .map((node) => walk(node, ROOT_COLLAPSIBLE)) + .map((node) => walk(node)) .filter((group): group is GroupNode => group !== null); const leftovers = entries.filter((entry) => !placed.has(entry.model)); diff --git a/apps/probe-viewer/src/grouping/types.ts b/apps/probe-viewer/src/grouping/types.ts index 2850bd4..cce7a2a 100644 --- a/apps/probe-viewer/src/grouping/types.ts +++ b/apps/probe-viewer/src/grouping/types.ts @@ -5,16 +5,14 @@ import type { ManifestEntry } from "../types/probe"; // model ids at the leaves. A manufacturer with no config renders as a flat list // (see ./index.ts). // -// Collapsibility is a per-node property that propagates to descendants: a node -// uses its own `collapsible` when set, otherwise it inherits the resolved value -// of its parent, and the top-level default is `true`. So only the exceptions -// need stating: the length bands set `collapsible: false`, which flows down to -// their probes, while everything else inherits the default and stays foldable. +// 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 HierarchyNode { label: string; - // true => a collapsible group header; false => a static, always-open divider. - // Omitted => inherit the parent's resolved value (root default: true). + // true (default) => a collapsible group header; false => a static, always-open + // divider. collapsible?: boolean; children?: HierarchyNode[]; probes?: string[]; // model ids, in display order From e05f699dbb881580b7ecd3111d7df906c485062e Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 9 Jun 2026 12:37:05 -0600 Subject: [PATCH 8/9] Improvements --- apps/probe-viewer/src/grouping/README.md | 24 +++++++++++-------- .../probe-viewer/src/grouping/groupEntries.ts | 4 ++-- .../src/grouping/imec_neuropixels.json | 1 + apps/probe-viewer/src/grouping/types.ts | 9 ++++--- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/probe-viewer/src/grouping/README.md b/apps/probe-viewer/src/grouping/README.md index 5571cce..2c05602 100644 --- a/apps/probe-viewer/src/grouping/README.md +++ b/apps/probe-viewer/src/grouping/README.md @@ -1,16 +1,12 @@ # Sidebar grouping The probe sidebar turns a manufacturer's flat probe list into a hierarchy from -an explicit, hand-curated config. There are no rules and no inference: every +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. -This replaced an earlier rule-based engine that derived the grouping by regex on -the part number and substring-matching the free-text description. Those rules -were fragile because the probe metadata does not carry the facts we group on -(generation, family, length), so we were reconstructing editorial decisions from -strings. The explicit file states those decisions directly instead. - ## Format JSON (`imec_neuropixels.json`), because Vite imports it with no extra @@ -21,7 +17,9 @@ 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. +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 @@ -32,5 +30,11 @@ manifest needs a home in the config. 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 here or in a `note` field on the -node. +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 index 85ca9b2..b812037 100644 --- a/apps/probe-viewer/src/grouping/groupEntries.ts +++ b/apps/probe-viewer/src/grouping/groupEntries.ts @@ -1,5 +1,5 @@ import type { ManifestEntry } from "../types/probe"; -import type { GroupNode, HierarchyConfig, HierarchyNode } from "./types"; +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 @@ -18,7 +18,7 @@ export function groupEntries( for (const entry of entries) byModel.set(entry.model, entry); const placed = new Set(); - const walk = (node: HierarchyNode): GroupNode | null => { + const walk = (node: DisplayCategory): GroupNode | null => { const collapsible = node.collapsible ?? true; if (node.children) { diff --git a/apps/probe-viewer/src/grouping/imec_neuropixels.json b/apps/probe-viewer/src/grouping/imec_neuropixels.json index dc5137c..857f8fc 100644 --- a/apps/probe-viewer/src/grouping/imec_neuropixels.json +++ b/apps/probe-viewer/src/grouping/imec_neuropixels.json @@ -25,6 +25,7 @@ { "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"] }, diff --git a/apps/probe-viewer/src/grouping/types.ts b/apps/probe-viewer/src/grouping/types.ts index cce7a2a..3d6f8aa 100644 --- a/apps/probe-viewer/src/grouping/types.ts +++ b/apps/probe-viewer/src/grouping/types.ts @@ -9,17 +9,20 @@ import type { ManifestEntry } from "../types/probe"; // 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 HierarchyNode { +export interface DisplayCategory { label: string; // true (default) => a collapsible group header; false => a static, always-open // divider. collapsible?: boolean; - children?: HierarchyNode[]; + // 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: HierarchyNode[]; + hierarchy: DisplayCategory[]; } // One node of the resolved tree the walker returns, with manifest entries From a83512fde7692fbca1ecf799549bc7d1873d5c38 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 9 Jun 2026 17:39:23 -0600 Subject: [PATCH 9/9] bif refactor of coupling logic --- apps/probe-viewer/src/App.tsx | 177 ++---------------- .../src/components/ProbeCanvas.tsx | 24 +-- .../src/components/ProbeOverview.tsx | 15 +- .../src/components/ProbeViewer.tsx | 28 ++- apps/probe-viewer/src/state/useAppStore.ts | 32 +++- .../src/state/useProbeRouteSync.ts | 112 +++++++++++ .../src/state/useRestoreCameraFromUrl.ts | 46 +++++ .../src/state/useSyncCameraToUrl.ts | 67 +++++++ apps/probe-viewer/src/types/probe.ts | 9 + apps/probe-viewer/src/utils/exportUtils.ts | 32 ++-- 10 files changed, 311 insertions(+), 231 deletions(-) create mode 100644 apps/probe-viewer/src/state/useProbeRouteSync.ts create mode 100644 apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts create mode 100644 apps/probe-viewer/src/state/useSyncCameraToUrl.ts 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() {