|
1 | | -import { useEffect, useMemo } from "react"; |
| 1 | +import { useEffect, useMemo, useState } from "react"; |
2 | 2 |
|
3 | 3 | import { useAppStore } from "../state/useAppStore"; |
| 4 | +import type { ManifestEntry } from "../types/probe"; |
| 5 | +import { groupNeuropixels, variantLabel } from "../utils/neuropixelsGrouping"; |
4 | 6 |
|
5 | 7 | const MANUFACTURER_DISPLAY_NAMES: Record<string, string> = { |
6 | 8 | cambridgeneurotech: "Cambridge NeuroTech", |
@@ -59,6 +61,60 @@ export function Sidebar() { |
59 | 61 | } |
60 | 62 | }, [filteredEntries, selectedProbeId, selectProbe]); |
61 | 63 |
|
| 64 | + // Neuropixels (imec) gets a hierarchy-grouped list (platform -> family); |
| 65 | + // every other manufacturer keeps the simple flat list. |
| 66 | + const isNeuropixels = selectedManufacturer === "imec"; |
| 67 | + const groups = useMemo( |
| 68 | + () => (isNeuropixels ? groupNeuropixels(filteredEntries) : []), |
| 69 | + [isNeuropixels, filteredEntries], |
| 70 | + ); |
| 71 | + |
| 72 | + // Both levels start collapsed (empty expanded sets). A platform shows its |
| 73 | + // families only when expanded; a family shows its probes only when expanded. |
| 74 | + const [expandedPlatforms, setExpandedPlatforms] = useState<Set<string>>( |
| 75 | + () => new Set(), |
| 76 | + ); |
| 77 | + const [expandedFamilies, setExpandedFamilies] = useState<Set<string>>( |
| 78 | + () => new Set(), |
| 79 | + ); |
| 80 | + const togglePlatform = (platform: string) => |
| 81 | + setExpandedPlatforms((prev) => { |
| 82 | + const next = new Set(prev); |
| 83 | + if (next.has(platform)) next.delete(platform); |
| 84 | + else next.add(platform); |
| 85 | + return next; |
| 86 | + }); |
| 87 | + const toggleFamily = (key: string) => |
| 88 | + setExpandedFamilies((prev) => { |
| 89 | + const next = new Set(prev); |
| 90 | + if (next.has(key)) next.delete(key); |
| 91 | + else next.add(key); |
| 92 | + return next; |
| 93 | + }); |
| 94 | + |
| 95 | + const renderItem = (entry: ManifestEntry, showVariant: boolean) => ( |
| 96 | + <button |
| 97 | + key={entry.id} |
| 98 | + type="button" |
| 99 | + className={ |
| 100 | + entry.id === selectedProbeId |
| 101 | + ? "sidebar-item sidebar-item--active" |
| 102 | + : "sidebar-item" |
| 103 | + } |
| 104 | + onClick={() => selectProbe(entry.id)} |
| 105 | + > |
| 106 | + <span className="sidebar-item-name"> |
| 107 | + {entry.displayName} |
| 108 | + {showVariant && variantLabel(entry) ? ( |
| 109 | + <span className="sidebar-item-variant"> {variantLabel(entry)}</span> |
| 110 | + ) : null} |
| 111 | + </span> |
| 112 | + <span className="sidebar-item-meta"> |
| 113 | + {entry.contactCount} contacts · {entry.shankCount} shanks |
| 114 | + </span> |
| 115 | + </button> |
| 116 | + ); |
| 117 | + |
62 | 118 | return ( |
63 | 119 | <div className="sidebar"> |
64 | 120 | <header className="sidebar-header"> |
@@ -110,23 +166,73 @@ export function Sidebar() { |
110 | 166 | {manifestStatus === "success" && filteredEntries.length === 0 && ( |
111 | 167 | <p className="sidebar-hint">No probes match the current filters.</p> |
112 | 168 | )} |
113 | | - {filteredEntries.map((entry) => ( |
114 | | - <button |
115 | | - key={entry.id} |
116 | | - type="button" |
117 | | - className={ |
118 | | - entry.id === selectedProbeId |
119 | | - ? "sidebar-item sidebar-item--active" |
120 | | - : "sidebar-item" |
121 | | - } |
122 | | - onClick={() => selectProbe(entry.id)} |
123 | | - > |
124 | | - <span className="sidebar-item-name">{entry.displayName}</span> |
125 | | - <span className="sidebar-item-meta"> |
126 | | - {entry.contactCount} contacts · {entry.shankCount} shanks |
127 | | - </span> |
128 | | - </button> |
129 | | - ))} |
| 169 | + |
| 170 | + {manifestStatus === "success" && |
| 171 | + !isNeuropixels && |
| 172 | + filteredEntries.map((entry) => renderItem(entry, false))} |
| 173 | + |
| 174 | + {manifestStatus === "success" && |
| 175 | + isNeuropixels && |
| 176 | + groups.map((group) => { |
| 177 | + const platformOpen = expandedPlatforms.has(group.platform); |
| 178 | + return ( |
| 179 | + <div className="sidebar-group" key={group.platform}> |
| 180 | + <button |
| 181 | + type="button" |
| 182 | + className="sidebar-group-header" |
| 183 | + aria-expanded={platformOpen} |
| 184 | + onClick={() => togglePlatform(group.platform)} |
| 185 | + > |
| 186 | + <span className="sidebar-group-caret"> |
| 187 | + {platformOpen ? "▾" : "▸"} |
| 188 | + </span> |
| 189 | + <span className="sidebar-group-title">{group.platform}</span> |
| 190 | + <span className="sidebar-group-count">{group.count}</span> |
| 191 | + </button> |
| 192 | + {platformOpen && |
| 193 | + group.families.map((fam) => { |
| 194 | + const familyKey = `${group.platform}||${fam.family}`; |
| 195 | + const familyOpen = expandedFamilies.has(familyKey); |
| 196 | + return ( |
| 197 | + <div className="sidebar-subgroup" key={fam.family}> |
| 198 | + <button |
| 199 | + type="button" |
| 200 | + className="sidebar-subgroup-header" |
| 201 | + aria-expanded={familyOpen} |
| 202 | + onClick={() => toggleFamily(familyKey)} |
| 203 | + > |
| 204 | + <span className="sidebar-group-caret"> |
| 205 | + {familyOpen ? "▾" : "▸"} |
| 206 | + </span> |
| 207 | + <span className="sidebar-subgroup-title"> |
| 208 | + {fam.family} |
| 209 | + </span> |
| 210 | + <span className="sidebar-group-count"> |
| 211 | + {fam.entries.length} |
| 212 | + </span> |
| 213 | + </button> |
| 214 | + {familyOpen && |
| 215 | + fam.subgroups.map((sub) => ( |
| 216 | + <div |
| 217 | + className="sidebar-subdivision" |
| 218 | + key={sub.label || "_flat"} |
| 219 | + > |
| 220 | + {sub.label && ( |
| 221 | + <p className="sidebar-subgroup-divider"> |
| 222 | + {sub.label} |
| 223 | + </p> |
| 224 | + )} |
| 225 | + {sub.entries.map((entry) => |
| 226 | + renderItem(entry, true), |
| 227 | + )} |
| 228 | + </div> |
| 229 | + ))} |
| 230 | + </div> |
| 231 | + ); |
| 232 | + })} |
| 233 | + </div> |
| 234 | + ); |
| 235 | + })} |
130 | 236 | </div> |
131 | 237 | </div> |
132 | 238 | ); |
|
0 commit comments