Skip to content

Commit a83512f

Browse files
committed
bif refactor of coupling logic
1 parent e05f699 commit a83512f

10 files changed

Lines changed: 311 additions & 231 deletions

File tree

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}

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

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { useEffect, useRef, useMemo } from "react";
2-
import type { ProbeInterfaceFile } from "../types/probe";
2+
import type { ProbeInterfaceFile, ProbeViewerCamera } from "../types/probe";
33

44
interface ProbeOverviewProps {
55
probeData: ProbeInterfaceFile;
6-
zoom: number;
7-
viewCenterX: number | null; // probe coordinates (µm), null = geometry center
8-
viewCenterY: number | null;
6+
camera: ProbeViewerCamera;
97
/** Main canvas dimensions */
108
mainWidth: number;
119
mainHeight: number;
@@ -53,20 +51,19 @@ function computeGeometrySummary(probeData: ProbeInterfaceFile): GeometrySummary
5351

5452
export function ProbeOverview({
5553
probeData,
56-
zoom,
57-
viewCenterX,
58-
viewCenterY,
54+
camera,
5955
mainWidth,
6056
mainHeight,
6157
onViewCenterChange,
6258
}: ProbeOverviewProps) {
59+
const { zoom, centerX, centerY } = camera;
6360
const canvasRef = useRef<HTMLCanvasElement | null>(null);
6461
const geometry = useMemo(() => computeGeometrySummary(probeData), [probeData]);
6562
const probe = useMemo(() => probeData.probes?.[0], [probeData]);
6663

6764
// Calculate effective view center (use geometry center if null)
68-
const effectiveViewCenterX = viewCenterX ?? geometry?.centerX ?? 0;
69-
const effectiveViewCenterY = viewCenterY ?? geometry?.centerY ?? 0;
65+
const effectiveViewCenterX = centerX ?? geometry?.centerX ?? 0;
66+
const effectiveViewCenterY = centerY ?? geometry?.centerY ?? 0;
7067

7168
// Fixed minimap size
7269
const MINIMAP_WIDTH = 120;

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

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -94,25 +94,25 @@ export function ProbeViewer() {
9494
if (probeData && entry) {
9595
exportProbeAsPng(
9696
probeData,
97-
{ zoom: view.zoom, viewCenterX: view.viewCenterX, viewCenterY: view.viewCenterY },
97+
view.camera,
9898
{ width: canvasSize.width, height: canvasSize.height },
9999
`${entry.id}.png`,
100100
view.showScaleBar
101101
);
102102
}
103-
}, [probeData, entry, view.zoom, view.viewCenterX, view.viewCenterY, canvasSize.width, canvasSize.height, view.showScaleBar]);
103+
}, [probeData, entry, view.camera, canvasSize.width, canvasSize.height, view.showScaleBar]);
104104

105105
const handleExportSvg = useCallback(() => {
106106
if (probeData && entry) {
107107
exportProbeAsSvg(
108108
probeData,
109-
{ zoom: view.zoom, viewCenterX: view.viewCenterX, viewCenterY: view.viewCenterY },
109+
view.camera,
110110
{ width: canvasSize.width, height: canvasSize.height },
111111
`${entry.id}.svg`,
112112
view.showScaleBar
113113
);
114114
}
115-
}, [probeData, entry, view.zoom, view.viewCenterX, view.viewCenterY, canvasSize.width, canvasSize.height, view.showScaleBar]);
115+
}, [probeData, entry, view.camera, canvasSize.width, canvasSize.height, view.showScaleBar]);
116116

117117
const [shareCopied, setShareCopied] = useState(false);
118118
const handleShareView = useCallback(() => {
@@ -129,8 +129,8 @@ export function ProbeViewer() {
129129
// Get current view state directly from store (not stale closure value)
130130
// This is critical because App.tsx's URL effect may have updated the store
131131
// after this component rendered but before this effect runs
132-
const currentView = useAppStore.getState().view;
133-
const hasUrlViewState = currentView.zoom !== 1 || currentView.viewCenterX !== null || currentView.viewCenterY !== null;
132+
const currentCamera = useAppStore.getState().view.camera;
133+
const hasUrlViewState = currentCamera.zoom !== 1 || currentCamera.centerX !== null || currentCamera.centerY !== null;
134134
if (!hasUrlViewState) {
135135
resetView();
136136
}
@@ -152,8 +152,8 @@ export function ProbeViewer() {
152152
if (canvasSize.width === 0 || canvasSize.height === 0) return;
153153

154154
// Get current view state directly from store (not stale closure value)
155-
const currentView = useAppStore.getState().view;
156-
const hasUrlViewState = currentView.zoom !== 1 || currentView.viewCenterX !== null || currentView.viewCenterY !== null;
155+
const currentCamera = useAppStore.getState().view.camera;
156+
const hasUrlViewState = currentCamera.zoom !== 1 || currentCamera.centerX !== null || currentCamera.centerY !== null;
157157
if (hasUrlViewState) {
158158
lastSmartZoomProbeId.current = selectedProbeId;
159159
return;
@@ -279,14 +279,14 @@ export function ProbeViewer() {
279279
<div className="viewer-controls-group">
280280
<button
281281
type="button"
282-
onClick={() => setZoom(Math.min(view.zoom * 1.5, VIEW_ZOOM_MAX))}
282+
onClick={() => setZoom(Math.min(view.camera.zoom * 1.5, VIEW_ZOOM_MAX))}
283283
title="Zoom in"
284284
>
285285
{ZoomInIcon}
286286
</button>
287287
<button
288288
type="button"
289-
onClick={() => setZoom(Math.max(view.zoom / 1.5, VIEW_ZOOM_MIN))}
289+
onClick={() => setZoom(Math.max(view.camera.zoom / 1.5, VIEW_ZOOM_MIN))}
290290
title="Zoom out"
291291
>
292292
{ZoomOutIcon}
@@ -354,9 +354,7 @@ export function ProbeViewer() {
354354
<ProbeCanvas
355355
entry={entry}
356356
probeData={probeData}
357-
zoom={view.zoom}
358-
viewCenterX={view.viewCenterX}
359-
viewCenterY={view.viewCenterY}
357+
camera={view.camera}
360358
showContactIds={view.showContactIds}
361359
showScaleBar={view.showScaleBar}
362360
onViewCenterChange={(x, y) => setViewCenter(x, y)}
@@ -365,9 +363,7 @@ export function ProbeViewer() {
365363
{view.showOverview && (
366364
<ProbeOverview
367365
probeData={probeData}
368-
zoom={view.zoom}
369-
viewCenterX={view.viewCenterX}
370-
viewCenterY={view.viewCenterY}
366+
camera={view.camera}
371367
mainWidth={canvasSize.width}
372368
mainHeight={canvasSize.height}
373369
onViewCenterChange={(x, y) => setViewCenter(x, y)}

0 commit comments

Comments
 (0)