diff --git a/README.md b/README.md index 5d3300f..d988268 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,6 @@ To build and preview the `probe-viewer` web-app locally: ```bash cd apps/probe-viewer uv run build.py -# build -npm run build -# run -npx vite preview +# build & run +npm run build && npx vite preview ``` diff --git a/apps/probe-viewer/build.py b/apps/probe-viewer/build.py index 77f2e90..5c6c6ac 100755 --- a/apps/probe-viewer/build.py +++ b/apps/probe-viewer/build.py @@ -52,6 +52,7 @@ class ManifestEntry: json_url: str contact_count: int shank_count: int + num_sides: int has_3d_geometry: bool annotations: dict @@ -74,12 +75,18 @@ def load_probe_metadata(json_path: Path) -> ManifestEntry: if not probes: raise ValueError(f"No probes found in {json_path}") + if len(probes) > 1: + raise ValueError(f"Multiple probes found in {json_path}; expected one") + probe = probes[0] + manufacturer = json_path.parents[1].name model = json_path.parent.name probe_id = f"{manufacturer}:{model}" - total_contacts = sum(len(probe.get("contact_positions", [])) for probe in probes) - shank_count = max(len(set(probe.get("shank_ids") or [None])) for probe in probes) + total_contacts = len(probe.get("contact_positions", [])) + shank_count = len(set(probe.get("shank_ids") or [None])) + num_sides = 1 if probe.get("contact_sides") is None else len(set(probe.get("contact_sides"))) + has_3d = any(probe.get("ndim") == 3 for probe in probes) annotations = probes[0].get("annotations") or {} display_name = annotations.get("model_name") or model @@ -92,6 +99,7 @@ def load_probe_metadata(json_path: Path) -> ManifestEntry: json_url=json_path.name, contact_count=total_contacts, shank_count=shank_count, + num_sides=num_sides, has_3d_geometry=has_3d, annotations=annotations, ) diff --git a/apps/probe-viewer/src/components/Sidebar.tsx b/apps/probe-viewer/src/components/Sidebar.tsx index e90bb69..91a99cb 100644 --- a/apps/probe-viewer/src/components/Sidebar.tsx +++ b/apps/probe-viewer/src/components/Sidebar.tsx @@ -26,6 +26,19 @@ export function Sidebar() { const navigate = useNavigate(); const searchQuery = useAppStore((state) => state.searchQuery); const setSearchQuery = useAppStore((state) => state.setSearchQuery); + const sideFilter = useAppStore((state) => state.sideFilter); + const setSideFilter = useAppStore((state) => state.setSideFilter); + + // Only manufacturers with at least one multi-sided probe show the "sides" + // count; when every probe of a manufacturer is single-sided the metric is + // noise, so it's omitted for all of that manufacturer's items. + const multiSideManufacturers = useMemo(() => { + const set = new Set(); + manifest.forEach((entry) => { + if (entry.numSides > 1) set.add(entry.manufacturer); + }); + return set; + }, [manifest]); const manufacturers = useMemo(() => { const unique = new Set(); @@ -41,12 +54,30 @@ export function Sidebar() { } }, [manufacturers, selectedManufacturer, selectManufacturer]); + // Distinct side counts among the selected manufacturer's probes. The side + // dropdown only appears when this has more than one value (e.g. a catalog + // mixing single- and double-sided probes). + const availableSideCounts = useMemo(() => { + const counts = new Set(); + manifest.forEach((entry) => { + if (!selectedManufacturer || entry.manufacturer === selectedManufacturer) { + counts.add(entry.numSides); + } + }); + return Array.from(counts.values()).sort((a, b) => a - b); + }, [manifest, selectedManufacturer]); + + const showSideFilter = availableSideCounts.length > 1; + const filteredEntries = useMemo(() => { const query = searchQuery.trim().toLowerCase(); return manifest.filter((entry) => { if (selectedManufacturer && entry.manufacturer !== selectedManufacturer) { return false; } + if (sideFilter !== null && entry.numSides !== sideFilter) { + return false; + } if (!query) { return true; } @@ -55,7 +86,7 @@ export function Sidebar() { entry.displayName.toLowerCase().includes(query) ); }); - }, [manifest, selectedManufacturer, searchQuery]); + }, [manifest, selectedManufacturer, searchQuery, sideFilter]); useEffect(() => { // Re-pick a probe only when a stale one is selected (e.g. after switching @@ -111,6 +142,8 @@ export function Sidebar() { {entry.displayName} {entry.contactCount} contacts · {entry.shankCount} shanks + {multiSideManufacturers.has(entry.manufacturer) && + ` · ${entry.numSides} ${entry.numSides === 1 ? "side" : "sides"}`} ); @@ -226,6 +259,31 @@ export function Sidebar() { /> + {showSideFilter && ( +
+ + +
+ )} +
{manifestStatus === "loading" && (

Loading manifest…

diff --git a/apps/probe-viewer/src/services/manifest.ts b/apps/probe-viewer/src/services/manifest.ts index b80864d..d2bafce 100644 --- a/apps/probe-viewer/src/services/manifest.ts +++ b/apps/probe-viewer/src/services/manifest.ts @@ -11,6 +11,7 @@ function normalizeEntry(raw: RawManifestEntry): ManifestEntry { jsonUrl: `${import.meta.env.BASE_URL}${raw.json_url}`, contactCount: raw.contact_count, shankCount: raw.shank_count, + numSides: raw.num_sides, has3dGeometry: raw.has_3d_geometry, annotations: raw.annotations ?? {}, }; diff --git a/apps/probe-viewer/src/state/useAppStore.ts b/apps/probe-viewer/src/state/useAppStore.ts index 2904f13..64c5124 100644 --- a/apps/probe-viewer/src/state/useAppStore.ts +++ b/apps/probe-viewer/src/state/useAppStore.ts @@ -31,6 +31,9 @@ interface AppState { selectedManufacturer?: string; selectedProbeId?: string; searchQuery: string; + // Filter the list to probes with this many sides; null = show all. Only + // meaningful for manufacturers whose catalog mixes side counts. + sideFilter: number | null; probeCache: Record; probeStatus: Record; view: ViewState; @@ -42,6 +45,7 @@ interface AppState { loadManifest: () => Promise; selectManufacturer: (manufacturer?: string) => void; setSearchQuery: (query: string) => void; + setSideFilter: (sides: number | null) => void; selectProbe: (probeId?: string) => void; ensureProbeLoaded: (probeId: string) => Promise; setZoom: (zoom: number) => void; @@ -88,6 +92,7 @@ export const useAppStore = create((set, get) => ({ selectedManufacturer: undefined, selectedProbeId: undefined, searchQuery: "", + sideFilter: null, probeCache: {}, probeStatus: {}, view: INITIAL_VIEW_STATE, @@ -121,10 +126,15 @@ export const useAppStore = create((set, get) => ({ } }, - selectManufacturer: (manufacturer) => set({ selectedManufacturer: manufacturer }), + // Switching manufacturer clears the side filter — it's specific to whichever + // catalog was showing and rarely applies to the next one. + selectManufacturer: (manufacturer) => + set({ selectedManufacturer: manufacturer, sideFilter: null }), setSearchQuery: (query) => set({ searchQuery: query }), + setSideFilter: (sides) => set({ sideFilter: sides }), + selectProbe: (probeId) => set((state) => { if (!probeId) { diff --git a/apps/probe-viewer/src/types/probe.ts b/apps/probe-viewer/src/types/probe.ts index 9172e40..efccde7 100644 --- a/apps/probe-viewer/src/types/probe.ts +++ b/apps/probe-viewer/src/types/probe.ts @@ -6,6 +6,7 @@ export interface RawManifestEntry { json_url: string; contact_count: number; shank_count: number; + num_sides: number; has_3d_geometry: boolean; annotations: Record; } @@ -18,6 +19,7 @@ export interface ManifestEntry { jsonUrl: string; contactCount: number; shankCount: number; + numSides: number; has3dGeometry: boolean; annotations: Record; }