Skip to content

Commit 275ae28

Browse files
authored
Merge pull request #58 from h-mayorquin/improve_neurpixels_access
Improve Neuropixels Web App Representation
2 parents 68822db + a83512f commit 275ae28

19 files changed

Lines changed: 698 additions & 249 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ apps/probe-viewer/public/data/
33
apps/probe-viewer/public/probes-manifest.json
44
apps/probe-viewer/node_modules/
55
apps/probe-viewer/dist/
6+
apps/probe-viewer/.vite/
67

78
# Build cache
89
.cache/

apps/probe-viewer/src/App.css

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,99 @@
128128
color: #475569;
129129
}
130130

131+
/* Neuropixels hierarchy grouping */
132+
.sidebar-group {
133+
display: flex;
134+
flex-direction: column;
135+
gap: 0.4rem;
136+
}
137+
138+
.sidebar-group-header {
139+
display: flex;
140+
align-items: center;
141+
gap: 0.5rem;
142+
width: 100%;
143+
padding: 0.45rem 0.4rem;
144+
border: none;
145+
background: transparent;
146+
cursor: pointer;
147+
color: #0f172a;
148+
font-weight: 700;
149+
font-size: 0.95rem;
150+
border-bottom: 1px solid rgba(148, 163, 184, 0.35);
151+
}
152+
153+
.sidebar-group-header:hover {
154+
color: #2563eb;
155+
}
156+
157+
.sidebar-group-caret {
158+
width: 1rem;
159+
color: #64748b;
160+
}
161+
162+
.sidebar-group-title {
163+
flex: 1;
164+
text-align: left;
165+
}
166+
167+
.sidebar-group-count {
168+
font-size: 0.75rem;
169+
font-weight: 600;
170+
color: #475569;
171+
background: rgba(148, 163, 184, 0.25);
172+
border-radius: 999px;
173+
padding: 0.05rem 0.5rem;
174+
}
175+
176+
.sidebar-subgroup {
177+
display: flex;
178+
flex-direction: column;
179+
gap: 0.35rem;
180+
padding-left: 0.6rem;
181+
}
182+
183+
.sidebar-subgroup-header {
184+
display: flex;
185+
align-items: center;
186+
gap: 0.4rem;
187+
width: 100%;
188+
padding: 0.3rem 0.25rem;
189+
border: none;
190+
background: transparent;
191+
cursor: pointer;
192+
}
193+
194+
.sidebar-subgroup-header:hover .sidebar-subgroup-title {
195+
color: #2563eb;
196+
}
197+
198+
.sidebar-subgroup-title {
199+
flex: 1;
200+
text-align: left;
201+
font-size: 0.74rem;
202+
font-weight: 700;
203+
text-transform: uppercase;
204+
letter-spacing: 0.03em;
205+
color: #64748b;
206+
}
207+
208+
.sidebar-subdivision {
209+
display: flex;
210+
flex-direction: column;
211+
gap: 0.35rem;
212+
}
213+
214+
/* Static (non-collapsible) length divider inside the Non-human-primate family. */
215+
.sidebar-subgroup-divider {
216+
margin: 0.3rem 0 0.05rem 0.9rem;
217+
font-size: 0.68rem;
218+
font-weight: 600;
219+
text-transform: uppercase;
220+
letter-spacing: 0.04em;
221+
color: #94a3b8;
222+
}
223+
131224
.sidebar-hint {
132225
font-size: 0.9rem;
133226
color: #64748b;

apps/probe-viewer/src/App.tsx

Lines changed: 11 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -1,183 +1,28 @@
1-
import { useCallback, useEffect, useMemo, useRef } from "react";
2-
import {
3-
useLocation,
4-
useNavigate,
5-
useParams,
6-
useSearchParams,
7-
} from "react-router-dom";
1+
import { useEffect } from "react";
82

93
import { ProbeViewer } from "./components/ProbeViewer";
104
import { Sidebar } from "./components/Sidebar";
115
import { useAppStore } from "./state/useAppStore";
6+
import { useProbeRouteSync } from "./state/useProbeRouteSync";
7+
import { useRestoreCameraFromUrl } from "./state/useRestoreCameraFromUrl";
8+
import { useSyncCameraToUrl } from "./state/useSyncCameraToUrl";
129
import "./App.css";
1310

14-
const DEFAULT_PROBE_ID = "plexon:8S1024";
15-
16-
function roundForUrl(value: number, decimals = 1): number {
17-
const factor = Math.pow(10, decimals);
18-
return Math.round(value * factor) / factor;
19-
}
20-
2111
function App() {
22-
const { manufacturer, model } = useParams();
23-
const location = useLocation();
24-
const navigate = useNavigate();
25-
const [searchParams, setSearchParams] = useSearchParams();
26-
27-
const manifestStatus = useAppStore((state) => state.manifestStatus);
28-
const manifest = useAppStore((state) => state.manifest);
29-
const selectedProbeId = useAppStore((state) => state.selectedProbeId);
3012
const loadManifest = useAppStore((state) => state.loadManifest);
31-
const selectProbe = useAppStore((state) => state.selectProbe);
32-
33-
const view = useAppStore((state) => state.view);
34-
const setZoom = useAppStore((state) => state.setZoom);
35-
const setViewCenter = useAppStore((state) => state.setViewCenter);
3613

3714
useEffect(() => {
3815
void loadManifest();
3916
}, [loadManifest]);
4017

41-
// Track whether we've initialized from URL to avoid overwriting on first render
42-
const initializedFromUrl = useRef(false);
43-
44-
// Read view params from URL on initial load
45-
useEffect(() => {
46-
if (initializedFromUrl.current) return;
47-
initializedFromUrl.current = true;
48-
49-
const zoomParam = searchParams.get("zoom");
50-
const cxParam = searchParams.get("cx");
51-
const cyParam = searchParams.get("cy");
52-
53-
if (zoomParam) {
54-
const zoom = parseFloat(zoomParam);
55-
if (!isNaN(zoom)) setZoom(zoom);
56-
}
57-
if (cxParam && cyParam) {
58-
const cx = parseFloat(cxParam);
59-
const cy = parseFloat(cyParam);
60-
if (!isNaN(cx) && !isNaN(cy)) setViewCenter(cx, cy);
61-
}
62-
}, [searchParams, setZoom, setViewCenter]);
63-
64-
// Debounced URL update when view state changes
65-
const updateUrlTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
66-
const updateSearchParams = useCallback(() => {
67-
const { zoom, viewCenterX, viewCenterY } = view;
68-
const isDefault = zoom === 1 && viewCenterX === null && viewCenterY === null;
69-
70-
setSearchParams((prev) => {
71-
const next = new URLSearchParams(prev);
72-
if (isDefault) {
73-
next.delete("zoom");
74-
next.delete("cx");
75-
next.delete("cy");
76-
} else {
77-
next.set("zoom", String(roundForUrl(zoom, 2)));
78-
if (viewCenterX !== null && viewCenterY !== null) {
79-
next.set("cx", String(roundForUrl(viewCenterX, 1)));
80-
next.set("cy", String(roundForUrl(viewCenterY, 1)));
81-
} else {
82-
next.delete("cx");
83-
next.delete("cy");
84-
}
85-
}
86-
return next;
87-
}, { replace: true });
88-
}, [view, setSearchParams]);
89-
90-
useEffect(() => {
91-
if (!initializedFromUrl.current) return;
92-
93-
clearTimeout(updateUrlTimeout.current);
94-
updateUrlTimeout.current = setTimeout(updateSearchParams, 300);
95-
96-
return () => clearTimeout(updateUrlTimeout.current);
97-
}, [view.zoom, view.viewCenterX, view.viewCenterY, updateSearchParams]);
98-
99-
const manifestById = useMemo(() => {
100-
const map = new Map<string, typeof manifest[number]>();
101-
manifest.forEach((entry) => map.set(entry.id, entry));
102-
return map;
103-
}, [manifest]);
104-
105-
useEffect(() => {
106-
if (manifestStatus !== "success" || manifest.length === 0) {
107-
return;
108-
}
109-
110-
const routeId =
111-
manufacturer && model ? `${manufacturer}:${model}` : undefined;
112-
const routeEntry = routeId ? manifestById.get(routeId) : undefined;
113-
const currentSelected = selectedProbeId
114-
? manifestById.get(selectedProbeId)
115-
: undefined;
116-
117-
const getDefaultProbe = () =>
118-
manifestById.get(DEFAULT_PROBE_ID) ?? manifest[0];
119-
120-
if (selectedProbeId && !currentSelected) {
121-
const fallback = routeEntry ?? getDefaultProbe();
122-
if (fallback && fallback.id !== selectedProbeId) {
123-
selectProbe(fallback.id);
124-
}
125-
return;
126-
}
127-
128-
if (!selectedProbeId) {
129-
if (routeEntry) {
130-
selectProbe(routeEntry.id);
131-
} else {
132-
const fallback = getDefaultProbe();
133-
if (fallback) {
134-
selectProbe(fallback.id);
135-
}
136-
}
137-
}
138-
}, [
139-
manifestStatus,
140-
manifest,
141-
manifestById,
142-
manufacturer,
143-
model,
144-
selectedProbeId,
145-
selectProbe,
146-
]);
147-
148-
useEffect(() => {
149-
if (
150-
manifestStatus !== "success" ||
151-
!selectedProbeId ||
152-
manifest.length === 0
153-
) {
154-
return;
155-
}
156-
157-
const selectedEntry = manifestById.get(selectedProbeId);
158-
if (!selectedEntry) {
159-
return;
160-
}
161-
162-
const routeId =
163-
manufacturer && model ? `${manufacturer}:${model}` : undefined;
164-
if (routeId === selectedEntry.id) {
165-
return;
166-
}
18+
// Selected probe <-> URL path (/probes/:manufacturer/:model).
19+
useProbeRouteSync();
16720

168-
const targetPath = `/probes/${selectedEntry.manufacturer}/${selectedEntry.model}`;
169-
const replace = location.pathname === "/";
170-
navigate(targetPath, { replace });
171-
}, [
172-
manifestStatus,
173-
selectedProbeId,
174-
manifestById,
175-
manufacturer,
176-
model,
177-
navigate,
178-
location.pathname,
179-
manifest.length,
180-
]);
21+
// Camera <-> URL query string: restore from a shared link on load, then keep
22+
// the URL updated as the user zooms/pans. Coordinated via the store's
23+
// `cameraInitialized` flag so the writer can't clobber the link at mount.
24+
useRestoreCameraFromUrl();
25+
useSyncCameraToUrl();
18126

18227
return (
18328
<div className="app-shell">

apps/probe-viewer/src/components/ProbeCanvas.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,17 @@ import type {
1414

1515
import { useResizeObserver } from "../hooks/useResizeObserver";
1616
import { VIEW_ZOOM_MAX, VIEW_ZOOM_MIN } from "../state/useAppStore";
17-
import type { ContactShapeParams, ManifestEntry, ProbeInterfaceFile } from "../types/probe";
17+
import type {
18+
ContactShapeParams,
19+
ManifestEntry,
20+
ProbeInterfaceFile,
21+
ProbeViewerCamera,
22+
} from "../types/probe";
1823

1924
interface ProbeCanvasProps {
2025
entry: ManifestEntry;
2126
probeData: ProbeInterfaceFile;
22-
zoom: number;
23-
viewCenterX: number | null; // probe coordinates (µm), null = geometry center
24-
viewCenterY: number | null;
27+
camera: ProbeViewerCamera;
2528
showContactIds: boolean;
2629
showScaleBar: boolean;
2730
onViewCenterChange: (x: number | null, y: number | null) => void;
@@ -79,16 +82,15 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
7982
{
8083
entry,
8184
probeData,
82-
zoom,
83-
viewCenterX,
84-
viewCenterY,
85+
camera,
8586
showContactIds,
8687
showScaleBar,
8788
onViewCenterChange,
8889
onZoom,
8990
},
9091
ref
9192
) {
93+
const { zoom, centerX, centerY } = camera;
9294
const canvasRef = useRef<HTMLCanvasElement | null>(null);
9395

9496
// Expose canvas to parent for export
@@ -109,8 +111,8 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
109111
const probe = useMemo(() => probeData.probes?.[0], [probeData]);
110112

111113
// Calculate effective view center (use geometry center if null)
112-
const effectiveViewCenterX = viewCenterX ?? geometry?.centerX ?? 0;
113-
const effectiveViewCenterY = viewCenterY ?? geometry?.centerY ?? 0;
114+
const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0;
115+
const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0;
114116

115117
useEffect(() => {
116118
if (!canvasRef.current || !size.width || !size.height || !geometry || !probe) {
@@ -568,8 +570,8 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
568570
// read it directly (instead of parsing the hash). These mirror the URL
569571
// params: cx/cy are omitted at the default view, exactly like the URL.
570572
data-zoom={zoom}
571-
data-view-cx={viewCenterX ?? undefined}
572-
data-view-cy={viewCenterY ?? undefined}
573+
data-view-cx={centerX ?? undefined}
574+
data-view-cy={centerY ?? undefined}
573575
onPointerDown={handlePointerDown}
574576
onPointerMove={handlePointerMove}
575577
onPointerUp={handlePointerUp}

0 commit comments

Comments
 (0)