Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
93 changes: 93 additions & 0 deletions apps/probe-viewer/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
177 changes: 11 additions & 166 deletions apps/probe-viewer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | 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<string, typeof manifest[number]>();
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 (
<div className="app-shell">
Expand Down
24 changes: 13 additions & 11 deletions apps/probe-viewer/src/components/ProbeCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -79,16 +82,15 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
{
entry,
probeData,
zoom,
viewCenterX,
viewCenterY,
camera,
showContactIds,
showScaleBar,
onViewCenterChange,
onZoom,
},
ref
) {
const { zoom, centerX, centerY } = camera;
const canvasRef = useRef<HTMLCanvasElement | null>(null);

// Expose canvas to parent for export
Expand All @@ -109,8 +111,8 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
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) {
Expand Down Expand Up @@ -568,8 +570,8 @@ export const ProbeCanvas = forwardRef<HTMLCanvasElement, ProbeCanvasProps>(
// 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}
Expand Down
Loading
Loading