diff --git a/README.md b/README.md index 3352359..5d3300f 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,15 @@ See: The format of probes is JSON-based. See [documentation](https://probeinterface.readthedocs.io/en/main/format_spec.html) for full specifications. - +### Preview `probe-viewer` app locally + +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 +``` diff --git a/apps/probe-viewer/public/logos/README.md b/apps/probe-viewer/public/logos/README.md new file mode 100644 index 0000000..61b6259 --- /dev/null +++ b/apps/probe-viewer/public/logos/README.md @@ -0,0 +1,19 @@ +# Manufacturer logos + +Drop a logo here named `.png` to replace the wordmark on that +manufacturer's landing-page card. The key is the manufacturer folder name in the +repo root, e.g.: + +- `cambridgeneurotech.png` +- `diagnosticbiochips.png` +- `imec.png` +- `neuronexus.png` +- `plexon.png` +- `sinaps-research-platform.png` + +If no file is present, the card falls back to a brand-colored wordmark +(see `ProbeIndex.tsx`). Logos are landscape-friendly (rendered at 16:9, contained +on a white background). + +Only add logos you have the right to use. Manufacturer logos are trademarks; +they are intentionally not committed here by default. diff --git a/apps/probe-viewer/public/logos/cambridgeneurotech.png b/apps/probe-viewer/public/logos/cambridgeneurotech.png new file mode 100644 index 0000000..f22deaa Binary files /dev/null and b/apps/probe-viewer/public/logos/cambridgeneurotech.png differ diff --git a/apps/probe-viewer/public/logos/diagnosticbiochips.png b/apps/probe-viewer/public/logos/diagnosticbiochips.png new file mode 100644 index 0000000..ece7227 Binary files /dev/null and b/apps/probe-viewer/public/logos/diagnosticbiochips.png differ diff --git a/apps/probe-viewer/public/logos/imec.png b/apps/probe-viewer/public/logos/imec.png new file mode 100644 index 0000000..84084d6 Binary files /dev/null and b/apps/probe-viewer/public/logos/imec.png differ diff --git a/apps/probe-viewer/public/logos/neuronexus.svg b/apps/probe-viewer/public/logos/neuronexus.svg new file mode 100644 index 0000000..a79ea30 --- /dev/null +++ b/apps/probe-viewer/public/logos/neuronexus.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/probe-viewer/public/logos/plexon.png b/apps/probe-viewer/public/logos/plexon.png new file mode 100644 index 0000000..e039064 Binary files /dev/null and b/apps/probe-viewer/public/logos/plexon.png differ diff --git a/apps/probe-viewer/public/logos/sinaps-research-platform.svg b/apps/probe-viewer/public/logos/sinaps-research-platform.svg new file mode 100644 index 0000000..46797f5 --- /dev/null +++ b/apps/probe-viewer/public/logos/sinaps-research-platform.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index ff5e061..d7c0d14 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -1,13 +1,36 @@ +:root { + /* "Instrument" palette: deep ink + cool neutrals, one restrained accent. The + warm gold/bronze is reserved for the probe rendering (the hero), so it is + intentionally absent from the chrome. */ + --ink: #0b1220; + --ink-2: #334155; + --muted: #64748b; + --line: #dbe2ea; + --line-strong: #c3ccd8; + --surface: #ffffff; + --bg: #eceff3; + --bg-2: #e1e6ed; + /* Monochrome graphite accent: the chrome carries no hue, so the warm gold of + the probe is the only color in the app. */ + --accent: #334155; + --accent-strong: #0f172a; + --accent-soft: rgba(15, 23, 42, 0.06); + --radius: 0.6rem; + --radius-lg: 0.9rem; + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05); + --shadow: 0 10px 30px rgba(15, 23, 42, 0.08); +} + .app-shell { display: flex; height: 100%; overflow: hidden; - background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%); + background: linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%); } .app-sidebar { width: 320px; - border-right: 1px solid rgba(15, 23, 42, 0.08); + border-right: 1px solid var(--line); background: rgba(255, 255, 255, 0.9); backdrop-filter: blur(6px); } @@ -28,7 +51,7 @@ gap: 1.25rem; height: 100%; padding: 2rem 1.5rem; - color: #0f172a; + color: var(--ink); } .sidebar-header { @@ -38,13 +61,16 @@ } .sidebar-title { - font-size: 1.5rem; + font-size: 1.4rem; margin: 0; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--ink); } .sidebar-subtitle { margin: 0; - color: #475569; + color: var(--ink-2); font-size: 0.95rem; } @@ -57,23 +83,23 @@ .sidebar-label { font-size: 0.85rem; font-weight: 600; - color: #475569; + color: var(--ink-2); } .sidebar select, .sidebar input { font: inherit; padding: 0.5rem 0.75rem; - border-radius: 0.6rem; - border: 1px solid rgba(100, 116, 139, 0.3); - background-color: #f8fafc; + border-radius: var(--radius); + border: 1px solid var(--line-strong); + background-color: var(--bg); transition: border-color 0.2s ease, box-shadow 0.2s ease; } .sidebar select:focus, .sidebar input:focus { - border-color: #2563eb; - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); outline: none; } @@ -100,32 +126,34 @@ gap: 0.25rem; width: 100%; padding: 0.8rem 0.9rem; - border-radius: 0.9rem; - border: 1px solid rgba(148, 163, 184, 0.4); - background: rgba(248, 250, 252, 0.8); + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: var(--surface); cursor: pointer; - transition: background 0.2s ease, border-color 0.2s ease, transform 0.1s ease; + transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; } .sidebar-item:hover { - border-color: rgba(37, 99, 235, 0.4); - background: rgba(191, 219, 254, 0.35); + border-color: var(--line-strong); + background: var(--accent-soft); } +/* Active probe: a graphite left bar (inset shadow, so no layout shift) plus a + darker border. Monochrome, distinct from the lighter hover fill. */ .sidebar-item--active { - border-color: rgba(37, 99, 235, 0.8); - background: rgba(191, 219, 254, 0.6); - box-shadow: 0 8px 18px rgba(37, 99, 235, 0.15); + border-color: var(--accent-strong); + background: var(--accent-soft); + box-shadow: inset 3px 0 0 var(--accent-strong), var(--shadow-sm); } .sidebar-item-name { font-weight: 600; - color: #0f172a; + color: var(--ink); } .sidebar-item-meta { font-size: 0.8rem; - color: #475569; + color: var(--ink-2); } /* Neuropixels hierarchy grouping */ @@ -144,19 +172,19 @@ border: none; background: transparent; cursor: pointer; - color: #0f172a; + color: var(--ink); font-weight: 700; font-size: 0.95rem; - border-bottom: 1px solid rgba(148, 163, 184, 0.35); + border-bottom: 1px solid var(--line); } .sidebar-group-header:hover { - color: #2563eb; + color: var(--accent-strong); } .sidebar-group-caret { width: 1rem; - color: #64748b; + color: var(--muted); } .sidebar-group-title { @@ -167,8 +195,8 @@ .sidebar-group-count { font-size: 0.75rem; font-weight: 600; - color: #475569; - background: rgba(148, 163, 184, 0.25); + color: var(--ink-2); + background: var(--accent-soft); border-radius: 999px; padding: 0.05rem 0.5rem; } @@ -192,7 +220,7 @@ } .sidebar-subgroup-header:hover .sidebar-subgroup-title { - color: #2563eb; + color: var(--accent-strong); } .sidebar-subgroup-title { @@ -202,7 +230,7 @@ font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; - color: #64748b; + color: var(--muted); } .sidebar-subdivision { @@ -218,12 +246,12 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: #94a3b8; + color: var(--muted); } .sidebar-hint { font-size: 0.9rem; - color: #64748b; + color: var(--muted); margin: 0.5rem 0; } @@ -241,10 +269,11 @@ gap: 1rem; max-width: 960px; width: 100%; - background: rgba(255, 255, 255, 0.95); - border-radius: 1.25rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); padding: 2rem; - box-shadow: 0 20px 45px rgba(15, 23, 42, 0.08); + box-shadow: var(--shadow); } .viewer-header { @@ -256,7 +285,10 @@ .viewer-title { margin: 0; - font-size: 1.75rem; + font-size: 1.7rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--ink); } /* "{ } JSON" link inline in the subtitle metadata line. It uses the blue link @@ -268,7 +300,7 @@ gap: 0.25rem; vertical-align: middle; /* align the icon + text with the surrounding subtitle text */ font-weight: 600; - color: #2563eb; + color: var(--accent); text-decoration: none; transition: color 0.2s ease; } @@ -279,7 +311,7 @@ } .viewer-json-link:hover { - color: #1d4ed8; + color: var(--accent-strong); } .viewer-subtitle { @@ -288,46 +320,122 @@ font-size: 0.95rem; } -.viewer-controls { +/* Map-style overlay clusters floating in the canvas corners, so the probe owns + the frame and the chrome sits out of the way until you reach for it. */ +.canvas-controls { + position: absolute; + top: 0.75rem; + z-index: 4; display: flex; flex-wrap: wrap; + gap: 0.4rem; +} + +.canvas-controls--nav { + left: 0.75rem; +} + +.canvas-controls button { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 0.75rem; + justify-content: center; + gap: 0.35rem; + font: inherit; + font-size: 0.9rem; + padding: 0.4rem 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--ink-2); + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} + +.canvas-controls button:hover { + border-color: var(--accent); + color: var(--accent-strong); + background: var(--accent-soft); } -.viewer-controls-group { +/* Control bands above (framing) and below (display preferences) the canvas, so + the canvas itself carries only the zoom overlay and nothing occludes the + probe. */ +.viewer-toolbar { display: flex; + flex-wrap: wrap; + align-items: center; gap: 0.5rem; } -.viewer-controls button { +.viewer-toolbar button { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; font: inherit; + font-size: 0.9rem; padding: 0.45rem 0.9rem; - border-radius: 0.75rem; - border: 1px solid rgba(37, 99, 235, 0.4); - background: rgba(191, 219, 254, 0.45); - color: #1d4ed8; + border-radius: var(--radius); + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--ink-2); cursor: pointer; - transition: transform 0.1s ease, box-shadow 0.2s ease, background 0.2s ease; + transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; } -.viewer-controls button:hover { - transform: translateY(-1px); - box-shadow: 0 10px 18px rgba(37, 99, 235, 0.2); - background: rgba(191, 219, 254, 0.7); +.viewer-toolbar button:hover { + border-color: var(--accent); + color: var(--accent-strong); + background: var(--accent-soft); } +/* Toggles styled as part of the button family: an outline chip that fills with + the accent when on, so they sit alongside the action buttons consistently. */ .viewer-toggle { display: inline-flex; align-items: center; - gap: 0.4rem; + gap: 0.45rem; font-size: 0.9rem; - color: #1e293b; + padding: 0.45rem 0.8rem; + border-radius: var(--radius); + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--ink-2); + cursor: pointer; + user-select: none; + transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; +} + +.viewer-toggle:hover { + border-color: var(--accent); + color: var(--accent-strong); +} + +.viewer-toggle:has(input:checked) { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-strong); +} + +/* Keyboard focus ring on the chip, since the real checkbox is visually hidden. */ +.viewer-toggle:has(input:focus-visible) { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* The checkbox stays in the DOM (and keyboard-focusable) for accessibility, but + is visually hidden: the chip's pressed fill is what conveys on/off. */ +.viewer-toggle input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; } .viewer-canvas { @@ -377,12 +485,12 @@ gap: 0.35rem; font-size: 0.9rem; padding: 0.5rem 0.75rem; - border-radius: 0.75rem; - border: 1px solid rgba(37, 99, 235, 0.5); - background: transparent; - color: #2563eb; + border-radius: var(--radius); + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--ink-2); text-decoration: none; - transition: all 0.2s ease; + transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease; cursor: pointer; } @@ -393,7 +501,9 @@ } .viewer-download:hover { - background: rgba(37, 99, 235, 0.1); + border-color: var(--accent); + color: var(--accent-strong); + background: var(--accent-soft); } .viewer-canvas-placeholder { @@ -429,7 +539,7 @@ } .viewer-issue-link a:hover { - color: #2563eb; + color: var(--accent-strong); text-decoration: underline; } @@ -463,3 +573,212 @@ padding: 1rem; } } + +/* ===== Catalog landing page ===== */ +.index { + height: 100%; + overflow-y: auto; + padding: 2rem 2.5rem 3rem; +} + +.index-header { + max-width: 1200px; + margin: 0 auto 1.5rem; +} + +.index-title { + margin: 0 0 0.3rem; + font-size: 1.8rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--ink); +} + +.index-subtitle { + margin: 0 0 1rem; + color: var(--muted); +} + +.index-controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.index-search, +.index-manufacturer { + font: inherit; + padding: 0.5rem 0.75rem; + border-radius: 0.6rem; + border: 1px solid rgba(148, 163, 184, 0.6); + background: #fff; +} + +.index-search { + flex: 1 1 16rem; + min-width: 12rem; +} + +.index-hint, +.index-error { + max-width: 1200px; + margin: 1rem auto; + color: #64748b; +} + +.index-error { + color: #b91c1c; +} + +.index-grid { + max-width: 1200px; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; +} + +/* Manufacturer landing: fewer, larger cards. */ +.index-grid--manufacturers { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.probe-card { + display: flex; + flex-direction: column; + text-align: left; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-sm); + cursor: pointer; + overflow: hidden; + font: inherit; + transition: transform 0.12s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.probe-card:hover { + transform: translateY(-2px); + border-color: var(--accent); + box-shadow: var(--shadow); +} + +.probe-card-image { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: contain; + background: #f8fafc; + border-bottom: 1px solid rgba(148, 163, 184, 0.3); +} + +.probe-card-image--empty { + display: flex; + align-items: center; + justify-content: center; + color: #94a3b8; + font-size: 0.85rem; +} + +/* Manufacturer logo: centered in a uniform tile, sized by height (with a width + cap) so logos of different aspect ratios read at a consistent scale. */ +.probe-card-logo-tile { + aspect-ratio: 5 / 2; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem 0.75rem; +} + +.probe-card-logo-img { + max-height: 5rem; + max-width: 97%; + width: auto; + height: auto; + object-fit: contain; +} + +/* Manufacturer wordmark cards (fallback when no logo file is present). */ +.probe-card-logo { + width: 100%; + aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + text-align: center; + background: #334155; +} + +.probe-card-logo-text { + color: #fff; + font-size: 1.4rem; + font-weight: 700; + letter-spacing: 0.01em; + line-height: 1.2; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); +} + +.probe-card-logo--cambridgeneurotech { + background: linear-gradient(135deg, #0e7490, #06b6d4); +} +.probe-card-logo--imec { + background: linear-gradient(135deg, #9f1239, #e11d48); +} +.probe-card-logo--neuronexus { + background: linear-gradient(135deg, #1e40af, #3b82f6); +} +.probe-card-logo--diagnosticbiochips { + background: linear-gradient(135deg, #6d28d9, #8b5cf6); +} +.probe-card-logo--plexon { + background: linear-gradient(135deg, #9a3412, #f97316); +} +.probe-card-logo--sinaps-research-platform { + background: linear-gradient(135deg, #115e59, #14b8a6); +} + +.probe-card-body { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.7rem 0.85rem 0.9rem; +} + +.probe-card-title { + font-weight: 600; + color: #0f172a; +} + +.probe-card-manufacturer { + font-size: 0.85rem; + color: #2563eb; +} + +.probe-card-meta { + font-size: 0.8rem; + color: #64748b; +} + +.sidebar-home { + display: inline-flex; + align-items: center; + gap: 0.35rem; + align-self: flex-start; + background: none; + border: none; + padding: 0; + margin-bottom: 0.6rem; + font: inherit; + font-size: 0.85rem; + font-weight: 600; + color: var(--muted); + cursor: pointer; + transition: color 0.15s ease; +} + +.sidebar-home:hover { + color: var(--ink); +} diff --git a/apps/probe-viewer/src/App.tsx b/apps/probe-viewer/src/App.tsx index ed9e109..84c57e8 100644 --- a/apps/probe-viewer/src/App.tsx +++ b/apps/probe-viewer/src/App.tsx @@ -1,5 +1,7 @@ import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { ProbeIndex } from "./components/ProbeIndex"; import { ProbeViewer } from "./components/ProbeViewer"; import { Sidebar } from "./components/Sidebar"; import { useAppStore } from "./state/useAppStore"; @@ -10,6 +12,8 @@ import "./App.css"; function App() { const loadManifest = useAppStore((state) => state.loadManifest); + // Present on /probes/:manufacturer/:model, absent on the bare "/" landing. + const { model } = useParams(); useEffect(() => { void loadManifest(); @@ -24,6 +28,11 @@ function App() { useRestoreCameraFromUrl(); useSyncCameraToUrl(); + // No probe in the route: show the catalog landing instead of a probe view. + if (!model) { + return ; + } + return (
- - -
-
- - -
-
- {hasContactIds && ( - - )} - - -
-
+ + + {status !== "error" && probeData && ( +
+ + +
+ )}
{status === "error" && ( @@ -355,6 +409,7 @@ export function ProbeViewer() { entry={entry} probeData={probeData} camera={view.camera} + maxZoom={view.maxZoom} showContactIds={view.showContactIds} showScaleBar={view.showScaleBar} onViewCenterChange={(x, y) => setViewCenter(x, y)} @@ -369,6 +424,23 @@ export function ProbeViewer() { onViewCenterChange={(x, y) => setViewCenter(x, y)} /> )} + +
+ + +
)} {status === "loading" && ( @@ -378,6 +450,40 @@ export function ProbeViewer() { )}
+ {status !== "error" && probeData && ( +
+ {hasContactIds && ( + + )} + + +
+ )} +
state.selectManufacturer); const selectedProbeId = useAppStore((state) => state.selectedProbeId); const selectProbe = useAppStore((state) => state.selectProbe); + const navigate = useNavigate(); const searchQuery = useAppStore((state) => state.searchQuery); const setSearchQuery = useAppStore((state) => state.setSearchQuery); @@ -56,7 +58,11 @@ export function Sidebar() { }, [manifest, selectedManufacturer, searchQuery]); useEffect(() => { + // Re-pick a probe only when a stale one is selected (e.g. after switching + // manufacturer). Do NOT auto-pick when nothing is selected, or the Home + // button (which clears the selection) would immediately bounce back here. if ( + selectedProbeId && filteredEntries.length > 0 && !filteredEntries.some((entry) => entry.id === selectedProbeId) ) { @@ -157,6 +163,31 @@ export function Sidebar() { return (
+

Probe Catalog

Browse available probe layouts and inspect their geometry. diff --git a/apps/probe-viewer/src/state/useAppStore.ts b/apps/probe-viewer/src/state/useAppStore.ts index f4b93a9..a6bb6f2 100644 --- a/apps/probe-viewer/src/state/useAppStore.ts +++ b/apps/probe-viewer/src/state/useAppStore.ts @@ -16,6 +16,9 @@ interface ViewState { showContactIds: boolean; showScaleBar: boolean; showOverview: boolean; + // Per-probe zoom ceiling, computed from geometry so the smallest contact can + // fill the viewport regardless of probe length (see setMaxZoom callers). + maxZoom: number; } interface AppState { @@ -39,6 +42,7 @@ interface AppState { selectProbe: (probeId?: string) => void; ensureProbeLoaded: (probeId: string) => Promise; setZoom: (zoom: number) => void; + setMaxZoom: (value: number) => void; setViewCenter: (x: number | null, y: number | null) => void; markCameraInitialized: () => void; resetView: () => void; @@ -48,7 +52,10 @@ interface AppState { } export const VIEW_ZOOM_MIN = 0.1; -export const VIEW_ZOOM_MAX = 100; // High max for long probes like Neuropixels +export const VIEW_ZOOM_MAX = 100; // Default ceiling until a per-probe cap is computed +// Hard ceiling purely against floating-point wobble at extreme scales; the real +// per-probe cap (view.maxZoom) is almost always well below this. +export const VIEW_ZOOM_ABSOLUTE_MAX = 1e5; const INITIAL_CAMERA: ProbeViewerCamera = { zoom: 1, @@ -61,6 +68,7 @@ const INITIAL_VIEW_STATE: ViewState = { showContactIds: false, showScaleBar: true, showOverview: true, + maxZoom: VIEW_ZOOM_MAX, }; function clamp(value: number, min: number, max: number) { @@ -181,11 +189,27 @@ export const useAppStore = create((set, get) => ({ ...state.view, camera: { ...state.view.camera, - zoom: clamp(zoom, VIEW_ZOOM_MIN, VIEW_ZOOM_MAX), + zoom: clamp(zoom, VIEW_ZOOM_MIN, state.view.maxZoom), }, }, })), + setMaxZoom: (value) => + set((state) => { + const maxZoom = clamp(value, VIEW_ZOOM_MIN, VIEW_ZOOM_ABSOLUTE_MAX); + return { + view: { + ...state.view, + maxZoom, + // Re-clamp the current zoom so a tighter cap pulls the view back in. + camera: { + ...state.view.camera, + zoom: Math.min(state.view.camera.zoom, maxZoom), + }, + }, + }; + }), + setViewCenter: (x, y) => set((state) => ({ view: { @@ -201,6 +225,8 @@ export const useAppStore = create((set, get) => ({ view: { ...INITIAL_VIEW_STATE, showContactIds: state.view.showContactIds, + // The cap is a property of the probe, not the camera; keep it across a reset. + maxZoom: state.view.maxZoom, }, })), diff --git a/apps/probe-viewer/src/state/useProbeRouteSync.ts b/apps/probe-viewer/src/state/useProbeRouteSync.ts index fd46537..454642a 100644 --- a/apps/probe-viewer/src/state/useProbeRouteSync.ts +++ b/apps/probe-viewer/src/state/useProbeRouteSync.ts @@ -1,20 +1,17 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useAppStore } from "./useAppStore"; -const DEFAULT_PROBE_ID = "plexon:8S1024"; - // Keeps the selected probe and the URL path (/probes/:manufacturer/:model) in // agreement: // -// select (path -> store) on load / manifest change, picks the probe named in -// the URL, falling back to a default -// sync (store -> path) navigates to match the selection when it changes -// -// Unlike the camera sync there is no shared flag: each effect carries its own -// loop-breaker (select no-ops when the selection is already valid; sync skips -// navigation when the path already matches), so the two cannot ping-pong. +// select (path -> store) acts only when the route's probe actually changes, +// so navigating away cannot re-add the probe we are +// leaving; the bare "/" landing clears the selection +// sync (store -> path) navigates to match the selection when it changes, +// reading the live selection so a same-commit clear +// (e.g. the Home button) is respected export function useProbeRouteSync() { const { manufacturer, model } = useParams(); const location = useLocation(); @@ -31,82 +28,60 @@ export function useProbeRouteSync() { return map; }, [manifest]); - // select: URL path -> store + // select: URL path -> store. Only react when the route's probe id actually + // changes (tracked via a ref). This is what prevents a bounce: during a + // navigation transient the route can briefly still name the old probe while + // the selection has been cleared, and without this guard the effect would + // re-select it. `null` is the "not yet run" sentinel; `undefined` is the + // landing route. + const lastRouteIdRef = useRef(null); useEffect(() => { - if (manifestStatus !== "success" || manifest.length === 0) { - return; - } + 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 (routeId === lastRouteIdRef.current) return; + lastRouteIdRef.current = routeId; - if (selectedProbeId && !currentSelected) { - const fallback = routeEntry ?? getDefaultProbe(); - if (fallback && fallback.id !== selectedProbeId) { - selectProbe(fallback.id); - } + if (!routeId) { + // Landed on the catalog: clear any selection so the sync effect does not + // pull us back into a probe view. + if (useAppStore.getState().selectedProbeId) selectProbe(undefined); return; } - if (!selectedProbeId) { - if (routeEntry) { - selectProbe(routeEntry.id); - } else { - const fallback = getDefaultProbe(); - if (fallback) { - selectProbe(fallback.id); - } - } + const routeEntry = manifestById.get(routeId); + if (routeEntry && routeEntry.id !== useAppStore.getState().selectedProbeId) { + selectProbe(routeEntry.id); } - }, [ - manifestStatus, - manifest, - manifestById, - manufacturer, - model, - selectedProbeId, - selectProbe, - ]); + }, [manifestStatus, manifest, manifestById, manufacturer, model, selectProbe]); - // sync: store -> URL path + // sync: store -> URL path. useEffect(() => { - if ( - manifestStatus !== "success" || - !selectedProbeId || - manifest.length === 0 - ) { - return; - } + if (manifestStatus !== "success" || manifest.length === 0) return; - const selectedEntry = manifestById.get(selectedProbeId); - if (!selectedEntry) { - return; - } + // Read the live selection: if the select effect cleared it in this same + // commit (landing), we must see that and not navigate back into a probe. + const liveSelected = useAppStore.getState().selectedProbeId; + if (!liveSelected) return; + + const selectedEntry = manifestById.get(liveSelected); + if (!selectedEntry) return; const routeId = manufacturer && model ? `${manufacturer}:${model}` : undefined; - if (routeId === selectedEntry.id) { - return; - } + if (routeId === selectedEntry.id) return; const targetPath = `/probes/${selectedEntry.manufacturer}/${selectedEntry.model}`; - const replace = location.pathname === "/"; - navigate(targetPath, { replace }); + navigate(targetPath, { replace: location.pathname === "/" }); }, [ manifestStatus, - selectedProbeId, + manifest, manifestById, manufacturer, model, + selectedProbeId, navigate, location.pathname, - manifest.length, ]); } diff --git a/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts b/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts index 3113ed8..4d830dc 100644 --- a/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts +++ b/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts @@ -3,44 +3,70 @@ import { useSearchParams } from "react-router-dom"; import { useAppStore } from "./useAppStore"; -// Reads the camera params (zoom/cx/cy) from a shared link into the store. -function restoreCameraFromParams( +// Reads the camera (zoom/cx/cy) and view toggles (ids/scale/overview) from a +// shared link into the store. +function restoreViewFromParams( searchParams: URLSearchParams, setZoom: (zoom: number) => void, setViewCenter: (x: number | null, y: number | null) => void, + toggleContactIds: (value?: boolean) => void, + toggleScaleBar: (value?: boolean) => void, + toggleOverview: (value?: boolean) => void, ) { 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); } // A center needs both coordinates. If either is missing from the URL // (e.g. a link that only set zoom), there is no center to restore. + const cxParam = searchParams.get("cx"); + const cyParam = searchParams.get("cy"); const cx = cxParam ? parseFloat(cxParam) : NaN; const cy = cyParam ? parseFloat(cyParam) : NaN; - const hasCenter = !isNaN(cx) && !isNaN(cy); - if (hasCenter) { + if (!isNaN(cx) && !isNaN(cy)) { setViewCenter(cx, cy); } + + // Toggles are applied only when present, so a link that omits a flag leaves + // that toggle at its default. + if (searchParams.has("ids")) toggleContactIds(searchParams.get("ids") === "1"); + if (searchParams.has("scale")) toggleScaleBar(searchParams.get("scale") === "1"); + if (searchParams.has("overview")) toggleOverview(searchParams.get("overview") === "1"); } -// restore: URL -> store, once per page load. Applies a shared link's camera on -// mount, then flips `cameraInitialized` so the URL writer is allowed to start. -// The restore-before-write ordering this guarantees is documented on -// `cameraInitialized` in the store; useSyncCameraToUrl is the other half. +// restore: URL -> store, once per page load. Applies a shared link's camera and +// view toggles on mount, then flips `cameraInitialized` so the URL writer is +// allowed to start. useSyncCameraToUrl is the other half. export function useRestoreCameraFromUrl() { const [searchParams] = useSearchParams(); const cameraInitialized = useAppStore((state) => state.cameraInitialized); const setZoom = useAppStore((state) => state.setZoom); const setViewCenter = useAppStore((state) => state.setViewCenter); + const toggleContactIds = useAppStore((state) => state.toggleContactIds); + const toggleScaleBar = useAppStore((state) => state.toggleScaleBar); + const toggleOverview = useAppStore((state) => state.toggleOverview); const markCameraInitialized = useAppStore((state) => state.markCameraInitialized); useEffect(() => { if (cameraInitialized) return; - restoreCameraFromParams(searchParams, setZoom, setViewCenter); + restoreViewFromParams( + searchParams, + setZoom, + setViewCenter, + toggleContactIds, + toggleScaleBar, + toggleOverview, + ); markCameraInitialized(); - }, [cameraInitialized, searchParams, setZoom, setViewCenter, markCameraInitialized]); + }, [ + cameraInitialized, + searchParams, + setZoom, + setViewCenter, + toggleContactIds, + toggleScaleBar, + toggleOverview, + markCameraInitialized, + ]); } diff --git a/apps/probe-viewer/src/state/useSyncCameraToUrl.ts b/apps/probe-viewer/src/state/useSyncCameraToUrl.ts index c513997..2e7637e 100644 --- a/apps/probe-viewer/src/state/useSyncCameraToUrl.ts +++ b/apps/probe-viewer/src/state/useSyncCameraToUrl.ts @@ -12,19 +12,44 @@ function roundForUrl(value: number, decimals = 1): number { return Math.round(value * factor) / factor; } -// Writes the current camera into the query string, dropping the params entirely -// when the camera is back at its default (zoom 1, no center). -function writeCameraToParams( +interface ViewFlags { + showContactIds: boolean; + showScaleBar: boolean; + showOverview: boolean; +} + +// A flag is written to the URL only when it differs from its default, so a +// default view carries no flag params at all. +const FLAG_DEFAULTS: ViewFlags = { + showContactIds: false, + showScaleBar: true, + showOverview: true, +}; + +function setOrDeleteFlag( + params: URLSearchParams, + key: string, + value: boolean, + defaultValue: boolean, +) { + if (value === defaultValue) params.delete(key); + else params.set(key, value ? "1" : "0"); +} + +// Writes the camera and the view toggles into the query string, dropping each +// back out when it returns to its default so a default view has a clean URL. +function writeViewToParams( camera: ProbeViewerCamera, + flags: ViewFlags, setSearchParams: SetURLSearchParams, ) { const { zoom, centerX, centerY } = camera; - const isDefault = zoom === 1 && centerX === null && centerY === null; + const isDefaultCamera = zoom === 1 && centerX === null && centerY === null; setSearchParams( (prev) => { const next = new URLSearchParams(prev); - if (isDefault) { + if (isDefaultCamera) { next.delete("zoom"); next.delete("cx"); next.delete("cy"); @@ -38,19 +63,25 @@ function writeCameraToParams( next.delete("cy"); } } + setOrDeleteFlag(next, "ids", flags.showContactIds, FLAG_DEFAULTS.showContactIds); + setOrDeleteFlag(next, "scale", flags.showScaleBar, FLAG_DEFAULTS.showScaleBar); + setOrDeleteFlag(next, "overview", flags.showOverview, FLAG_DEFAULTS.showOverview); return next; }, { replace: true }, ); } -// sync: store -> URL, debounced. Writes the current camera into the query string -// on every zoom/pan, but only once `cameraInitialized` is set, so it can't wipe -// a shared link's params before useRestoreCameraFromUrl has read them. The -// restore-before-write ordering is documented on `cameraInitialized` in the store. +// sync: store -> URL, debounced. Writes the camera and view toggles into the +// query string on every change, but only once `cameraInitialized` is set, so it +// can't wipe a shared link's params before useRestoreCameraFromUrl has read them. +// The restore-before-write ordering is documented on `cameraInitialized`. export function useSyncCameraToUrl() { const [, setSearchParams] = useSearchParams(); const camera = useAppStore((state) => state.view.camera); + const showContactIds = useAppStore((state) => state.view.showContactIds); + const showScaleBar = useAppStore((state) => state.view.showScaleBar); + const showOverview = useAppStore((state) => state.view.showOverview); const cameraInitialized = useAppStore((state) => state.cameraInitialized); const writeTimeout = useRef | undefined>(undefined); @@ -59,9 +90,13 @@ export function useSyncCameraToUrl() { clearTimeout(writeTimeout.current); writeTimeout.current = setTimeout(() => { - writeCameraToParams(camera, setSearchParams); + writeViewToParams( + camera, + { showContactIds, showScaleBar, showOverview }, + setSearchParams, + ); }, 300); return () => clearTimeout(writeTimeout.current); - }, [cameraInitialized, camera, setSearchParams]); + }, [cameraInitialized, camera, showContactIds, showScaleBar, showOverview, setSearchParams]); } diff --git a/apps/probe-viewer/src/utils/exportUtils.ts b/apps/probe-viewer/src/utils/exportUtils.ts index d5f94fe..74ea67e 100644 --- a/apps/probe-viewer/src/utils/exportUtils.ts +++ b/apps/probe-viewer/src/utils/exportUtils.ts @@ -347,53 +347,58 @@ function generateProbeSvgString( const elements: string[] = []; - // Probe contour + // Round emitted coordinates to 2 decimals: sub-pixel precision is invisible + // but keeps the markup compact and readable. + const r2 = (n: number) => Math.round(n * 100) / 100; + + // Probe contour: technical line-art — a faint cool wash so the shank reads as + // a region, with a thin precise outline. No fill gradient or shadow. if (probe.probe_planar_contour && probe.probe_planar_contour.length > 1) { const points = probe.probe_planar_contour - .map((p) => projectPoint(p).join(",")) + .map((p) => { + const [px, py] = projectPoint(p); + return `${r2(px)},${r2(py)}`; + }) .join(" "); - const strokeWidth = Math.max(1.2, 2.5 * (scale / 100)); + const strokeWidth = r2(Math.max(1, Math.min(1.6, 2 * (scale / 120)))); elements.push( - `` + `` ); } const contactPositions = probe.contact_positions ?? []; const contactShapes = probe.contact_shapes ?? []; const contactShapeParams = probe.contact_shape_params ?? []; - const shadowOffset = 0.4 * scale; // 0.4 micrometer offset for subtle depth - const contactStrokeWidth = Math.max(1.2, 2.5 * (scale / 150)); - // Helper to generate contact SVG element + // Helper to generate one contact's geometry. The flat-gold style (fill, + // bronze outline) is applied once on the wrapping , not per element. + // Rectangular pads get lightly rounded corners. const generateContactSvg = ( x: number, y: number, shape: string, - params: ContactShapeParams, - isShadow: boolean + params: ContactShapeParams ): string => { - const fill = isShadow ? "rgba(30, 20, 5, 0.7)" : "rgba(212, 175, 55, 1.0)"; - const stroke = isShadow ? "none" : "rgba(80, 60, 15, 0.9)"; - const sw = isShadow ? 0 : contactStrokeWidth; - switch (shape) { case "circle": { const radius = (params.radius ?? 5) * scale; - return ``; + return ``; } case "square": { const side = (params.width ?? 10) * scale; - return ``; + const rr = r2(side * 0.12); + return ``; } case "rect": { const w = (params.width ?? 10) * scale; const h = (params.height ?? 15) * scale; - return ``; + const rr = r2(Math.min(w, h) * 0.12); + return ``; } default: { - // Unknown shape: small circle + // Unknown shape: a small plain dot. const markerSize = Math.max(3, Math.min(10, 7 * (scale / 100))); - return ``; + return ``; } } }; @@ -404,32 +409,28 @@ function generateProbeSvgString( const size = Math.max((p.radius ?? 0) * 2, p.width ?? 0, p.height ?? 0); return Math.max(max, size); }, 10); - const frameMargin = maxContactSizeUm * scale + shadowOffset; + const frameMargin = maxContactSizeUm * scale + 4; const isContactInFrame = (x: number, y: number) => x >= -frameMargin && x <= widthPx + frameMargin && y >= -frameMargin && y <= heightPx + frameMargin; - // First pass: shadows + // All contacts share one flat-gold style, set once on a group wrapper. + const contactEls: string[] = []; contactPositions.forEach((position, index) => { const [x, y] = projectPoint(position); if (!isContactInFrame(x, y)) return; const shape = contactShapes[index] ?? ""; const params = contactShapeParams[index] ?? {}; + contactEls.push(generateContactSvg(x, y, shape, params)); + }); + if (contactEls.length > 0) { + const contactStrokeWidth = r2(Math.max(1, Math.min(1.8, 2.5 * (scale / 150)))); elements.push( - generateContactSvg(x + shadowOffset, y + shadowOffset, shape, params, true) + `\n${contactEls.join("\n")}\n` ); - }); - - // Second pass: gold contacts - contactPositions.forEach((position, index) => { - const [x, y] = projectPoint(position); - if (!isContactInFrame(x, y)) return; - const shape = contactShapes[index] ?? ""; - const params = contactShapeParams[index] ?? {}; - elements.push(generateContactSvg(x, y, shape, params, false)); - }); + } // Scale bar (L-shaped, bottom-left corner) if (showScaleBar) { @@ -447,26 +448,26 @@ function generateProbeSvgString( const tickSize = 4; const label = scaleBarUm >= 1000 ? `${scaleBarUm / 1000} mm` : `${scaleBarUm} μm`; - const strokeStyle = "rgba(15, 23, 42, 0.9)"; - - // L shape path - elements.push( - `` - ); - - // End ticks - elements.push( - `` - ); + const col = "rgba(15, 23, 42, 0.9)"; + const x0 = r2(cornerX); + const y0 = r2(cornerY); + const xEnd = r2(cornerX + scaleBarPixels); + const yTop = r2(cornerY - scaleBarPixels); - // X label (below horizontal arm) + // L shape + end ticks, sharing one stroke style on a group. elements.push( - `${label}` + `` + + `` + + `` + + `` ); - // Y label (rotated, to the left of vertical arm) + // Both labels share one text style on a group. elements.push( - `${label}` + `` + + `${label}` + + `${label}` + + `` ); }