diff --git a/.gitignore b/.gitignore index b8f9471..787cf16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Probe viewer generated files (created at build time) apps/probe-viewer/public/data/ apps/probe-viewer/public/probes-manifest.json +apps/probe-viewer/public/probe.schema.json apps/probe-viewer/node_modules/ apps/probe-viewer/dist/ apps/probe-viewer/.vite/ diff --git a/apps/probe-viewer/README.md b/apps/probe-viewer/README.md index 5c7e59e..f19ebcb 100644 --- a/apps/probe-viewer/README.md +++ b/apps/probe-viewer/README.md @@ -24,6 +24,8 @@ An interactive web-based visualization tool for browsing microelectrode probe de - Read the probe JSON files from the manufacturer folders in this repository - Generate `public/probes-manifest.json` with metadata for all probes - Copy probe JSON files to `public/data/` + - Download the ProbeInterface JSON schema to `public/probe.schema.json`, used to + validate probe files a user loads from their own computer - Start the Vite dev server 2. **Access the app:** @@ -78,6 +80,7 @@ apps/probe-viewer/ │ └── hooks/ # Custom React hooks ├── public/ │ ├── probes-manifest.json # Generated probe catalog +│ ├── probe.schema.json # Downloaded ProbeInterface schema │ └── data/ # Generated probe JSON files └── index.html ``` diff --git a/apps/probe-viewer/build.py b/apps/probe-viewer/build.py index 5c6c6ac..880b81a 100755 --- a/apps/probe-viewer/build.py +++ b/apps/probe-viewer/build.py @@ -22,6 +22,11 @@ manifest is generated directly from the manufacturer folders at the repo root. The manifest metadata is read straight from the ProbeInterface JSON, so this script has no third-party dependencies. + +It also downloads the ProbeInterface JSON schema into the app, so the viewer can +tell a user whether a file they load is spec-compliant. The schema is fetched +from the same upstream URL the data tests use (see tests.py), which keeps the +viewer's notion of "compliant" identical to the one CI enforces on this catalog. """ from __future__ import annotations @@ -31,10 +36,17 @@ import shutil import subprocess import sys +import urllib.request from dataclasses import asdict, dataclass from pathlib import Path from typing import Iterable +# The ProbeInterface JSON schema, from the same source tests.py validates against. +SCHEMA_URL = ( + "https://raw.githubusercontent.com/SpikeInterface/probeinterface/main/" + "src/probeinterface/schema/probe.json.schema" +) + # ============================================================================ # Manifest generation @@ -170,6 +182,20 @@ def generate_manifest( return entries +def download_schema(destination_dir: Path) -> Path: + # Kept in step with tests.py, which validates this catalog against the same + # URL: the viewer must not call a file non-compliant that CI accepts. The + # released package is deliberately not used as the source -- its schema + # predates contact_sides and would reject the double-sided probes in this + # very catalog. + with urllib.request.urlopen(SCHEMA_URL) as response: + schema = response.read() + + destination = destination_dir / "probe.schema.json" + destination.write_bytes(schema) + return destination + + def write_manifest(entries: Iterable[ManifestEntry], destination: Path) -> None: destination.parent.mkdir(parents=True, exist_ok=True) payload = [entry.to_json() for entry in entries] @@ -240,6 +266,9 @@ def main() -> None: write_manifest(entries, manifest_path) print(f"Wrote {len(entries)} entries to {manifest_path}") + schema_path = download_schema(public_dir) + print(f"Downloaded probe schema to {schema_path}") + if args.dev: # Start dev server print("Starting dev server...") diff --git a/apps/probe-viewer/package-lock.json b/apps/probe-viewer/package-lock.json index 0706909..166f5cd 100644 --- a/apps/probe-viewer/package-lock.json +++ b/apps/probe-viewer/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "ajv": "^8.20.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", @@ -860,6 +861,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -873,6 +891,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.37.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", @@ -1842,16 +1867,15 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -2284,6 +2308,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -2352,7 +2400,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -2399,6 +2446,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -2659,10 +2722,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -3061,6 +3123,15 @@ "react-dom": ">=18" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/apps/probe-viewer/package.json b/apps/probe-viewer/package.json index cc84bdd..8d1e023 100644 --- a/apps/probe-viewer/package.json +++ b/apps/probe-viewer/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "ajv": "^8.20.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.4", diff --git a/apps/probe-viewer/src/App.css b/apps/probe-viewer/src/App.css index 564c79d..48a2600 100644 --- a/apps/probe-viewer/src/App.css +++ b/apps/probe-viewer/src/App.css @@ -842,3 +842,149 @@ .sidebar-home:hover { color: var(--ink); } + +/* ===== Load-your-own-probe ===== */ +.local-loader-input { + display: none; +} + +/* Catalog header action. A secondary button, so browsing stays the page's + primary affordance. */ +.index-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.index-load-button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 0.9rem; + border-radius: 0.6rem; + border: 1px solid var(--line); + background: var(--surface); + color: var(--ink); + font: inherit; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease, box-shadow 0.2s ease; +} + +.index-load-button:hover { + border-color: var(--accent); + color: var(--accent-strong); + box-shadow: var(--shadow-sm); +} + +/* Shown only while a file is being dragged over the catalog. */ +.index-drop-overlay { + position: fixed; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.45); + backdrop-filter: blur(2px); + pointer-events: none; +} + +.index-drop-overlay-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 2.5rem 3rem; + border: 2px dashed rgba(255, 255, 255, 0.85); + border-radius: var(--radius-lg); + color: #fff; + font-size: 1.1rem; + font-weight: 600; +} + +.index-drop-overlay-inner p { + margin: 0; +} + +.index-drop-overlay-inner svg { + width: 28px; + height: 28px; +} + +/* Cold /local empty state. */ +.viewer-panel--empty { + align-items: center; + justify-content: center; +} + +.local-loader-panel { + max-width: 34rem; + margin: auto; + display: flex; + flex-direction: column; + gap: 1rem; + text-align: center; +} + +.local-loader-dropzone { + padding: 2.5rem 2rem; + border: 2px dashed var(--line); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.85); + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.local-loader-dropzone:hover, +.local-loader-dropzone.is-dragover { + border-color: var(--accent); + background: rgba(37, 99, 235, 0.06); +} + +.local-loader-title { + margin: 0 0 0.35rem; + font-size: 1.15rem; + font-weight: 600; + color: var(--ink); +} + +.local-loader-hint { + margin: 0; + color: var(--muted); +} + +/* Schema errors for a rejected file. /local is the only place these render, so + they get room to breathe (and to scroll when a file trips many rules). */ +.local-loader-error { + margin: 0; + padding: 0.75rem 1rem; + text-align: left; + color: #b91c1c; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 0.6rem; + font-size: 0.8rem; + white-space: pre-wrap; + max-height: 12rem; + overflow-y: auto; +} + +.local-loader-links { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.local-loader-link { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent); + cursor: pointer; + text-decoration: underline; +} diff --git a/apps/probe-viewer/src/App.tsx b/apps/probe-viewer/src/App.tsx index 84c57e8..0cb1608 100644 --- a/apps/probe-viewer/src/App.tsx +++ b/apps/probe-viewer/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useParams } from "react-router-dom"; +import { useLocation, useParams } from "react-router-dom"; import { ProbeIndex } from "./components/ProbeIndex"; import { ProbeViewer } from "./components/ProbeViewer"; @@ -14,6 +14,9 @@ function App() { const loadManifest = useAppStore((state) => state.loadManifest); // Present on /probes/:manufacturer/:model, absent on the bare "/" landing. const { model } = useParams(); + // /local renders the viewer for a user-loaded file even though it has no + // :model param, so it is distinguished from the "/" landing by pathname. + const isLocalRoute = useLocation().pathname === "/local"; useEffect(() => { void loadManifest(); @@ -25,11 +28,16 @@ function App() { // 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(); + // Both are scoped to the routes that actually show a probe; on the catalog + // landing there is no camera, and running them there left the last probe's + // zoom stuck on the URL (each hook re-feeding the other). + const isProbeInView = !!model || isLocalRoute; + useRestoreCameraFromUrl(isProbeInView); + useSyncCameraToUrl(isProbeInView); // No probe in the route: show the catalog landing instead of a probe view. - if (!model) { + // /local is the exception: it renders the viewer for a locally loaded file. + if (!isProbeInView) { return ; } diff --git a/apps/probe-viewer/src/components/LocalProbePanel.tsx b/apps/probe-viewer/src/components/LocalProbePanel.tsx new file mode 100644 index 0000000..f96919f --- /dev/null +++ b/apps/probe-viewer/src/components/LocalProbePanel.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useLocalProbeLoader } from "../hooks/useLocalProbeLoader"; +import { useAppStore } from "../state/useAppStore"; + +// probeinterface documentation for producing a compliant file, linked so a user +// who has no file knows how to export one. +const WRITE_DOCS_URL = + "https://probeinterface.readthedocs.io/en/main/api.html#probeinterface.io.write_probeinterface"; + +// The /local screen when no probe is loaded: either a cold visit (local bytes +// cannot survive a reload) or a file we rejected, in which case the schema +// errors are shown above the dropzone so the user can fix and retry. +export function LocalProbePanel() { + const { fileInputProps, loadFile, openPicker, loading } = useLocalProbeLoader(); + const localProbeError = useAppStore((state) => state.localProbeError); + const navigate = useNavigate(); + const [dragOver, setDragOver] = useState(false); + + return ( +
+
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openPicker(); + } + }} + onDragOver={(event) => { + event.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(event) => { + event.preventDefault(); + setDragOver(false); + void loadFile(event.dataTransfer.files?.[0]); + }} + > + +

+ {loading + ? "Loading…" + : localProbeError + ? "This file could not be loaded" + : "No probe loaded"} +

+

+ Drop a probeinterface JSON file here, or click to browse. +

+
+ + {localProbeError && ( +
{localProbeError}
+ )} + +

+ Need a file?{" "} + {" "} + or export one with{" "} + + write_probeinterface + + . +

+
+ ); +} diff --git a/apps/probe-viewer/src/components/ProbeIndex.tsx b/apps/probe-viewer/src/components/ProbeIndex.tsx index c324cc6..2ec00f2 100644 --- a/apps/probe-viewer/src/components/ProbeIndex.tsx +++ b/apps/probe-viewer/src/components/ProbeIndex.tsx @@ -1,5 +1,6 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; +import { useLocalProbeLoader } from "../hooks/useLocalProbeLoader"; import { useAppStore } from "../state/useAppStore"; import type { ManifestEntry } from "../types/probe"; @@ -53,12 +54,40 @@ interface ManufacturerGroup { entries: ManifestEntry[]; } +const UploadIcon = ( + +); + // Landing page: one card per manufacturer. Selecting a card enters the existing // probe view (sidebar + viewer) on that manufacturer's first probe. export function ProbeIndex() { const manifest = useAppStore((state) => state.manifest); const manifestStatus = useAppStore((state) => state.manifestStatus); const selectProbe = useAppStore((state) => state.selectProbe); + const { fileInputProps, loadFile, openPicker } = useLocalProbeLoader(); + + // Dragging a file anywhere over the catalog reveals a drop target, so the + // capability surfaces exactly when the user's intent is evident rather than + // costing a permanent dropzone on a page whose job is browsing. dragDepth + // counts enter/leave across nested children, which would otherwise flicker. + const [dragActive, setDragActive] = useState(false); + const dragDepth = useRef(0); + const isFileDrag = (event: React.DragEvent) => + event.dataTransfer.types.includes("Files"); const groups = useMemo(() => { const map = new Map(); @@ -73,9 +102,56 @@ export function ProbeIndex() { }, [manifest]); return ( -
+
{ + if (!isFileDrag(event)) return; + dragDepth.current += 1; + setDragActive(true); + }} + onDragOver={(event) => { + // Required for the drop event to fire at all. + if (isFileDrag(event)) event.preventDefault(); + }} + onDragLeave={() => { + dragDepth.current -= 1; + if (dragDepth.current <= 0) { + dragDepth.current = 0; + setDragActive(false); + } + }} + onDrop={(event) => { + if (!isFileDrag(event)) return; + event.preventDefault(); + dragDepth.current = 0; + setDragActive(false); + void loadFile(event.dataTransfer.files?.[0]); + }} + > + + + {dragActive && ( +
+
+ {UploadIcon} +

Drop your probeinterface JSON to load it

+
+
+ )} +
-

Probe Catalog

+
+

Probe Catalog

+ +

Select a manufacturer to browse its probes.

diff --git a/apps/probe-viewer/src/components/ProbeViewer.tsx b/apps/probe-viewer/src/components/ProbeViewer.tsx index 0d97206..c43dbe1 100644 --- a/apps/probe-viewer/src/components/ProbeViewer.tsx +++ b/apps/probe-viewer/src/components/ProbeViewer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useLocation, useSearchParams } from "react-router-dom"; import { useResizeObserver } from "../hooks/useResizeObserver"; import { useAppStore } from "../state/useAppStore"; @@ -8,6 +8,7 @@ import { getSideInfo } from "../geometry/sides"; import type { ContactShapeParams, ProbeInterfaceProbe } from "../types/probe"; import { ProbeCanvas } from "./ProbeCanvas"; import { DoubleSidedProbeCanvas } from "./DoubleSidedProbeCanvas"; +import { LocalProbePanel } from "./LocalProbePanel"; import { ProbeOverview } from "./ProbeOverview"; const CANVAS_PADDING = 40; @@ -170,6 +171,7 @@ export function ProbeViewer() { const ensureProbeLoaded = useAppStore((state) => state.ensureProbeLoaded); const probeCache = useAppStore((state) => state.probeCache); const probeStatus = useAppStore((state) => state.probeStatus); + const localProbe = useAppStore((state) => state.localProbe); const view = useAppStore((state) => state.view); const setZoom = useAppStore((state) => state.setZoom); const setMaxZoom = useAppStore((state) => state.setMaxZoom); @@ -180,16 +182,24 @@ export function ProbeViewer() { const toggleOverview = useAppStore((state) => state.toggleOverview); const setOverlaySide = useAppStore((state) => state.setOverlaySide); + const isLocalRoute = useLocation().pathname === "/local"; + useEffect(() => { if (selectedProbeId) { void ensureProbeLoaded(selectedProbeId); } }, [selectedProbeId, ensureProbeLoaded]); + // The "active" probe is the manifest selection when there is one, otherwise a + // locally loaded file (rendered at /local). Downstream code uses these two + // regardless of source; only the header affordances branch on isLocalProbe. const entry = useMemo( - () => manifest.find((item) => item.id === selectedProbeId), - [manifest, selectedProbeId], + () => manifest.find((item) => item.id === selectedProbeId) ?? localProbe?.entry, + [manifest, selectedProbeId, localProbe], ); + const isLocalProbe = !!localProbe && entry === localProbe.entry; + // Stable identity for the framing/reset effects, spanning both sources. + const activeKey = selectedProbeId ?? localProbe?.entry.id; const status = selectedProbeId ? probeStatus[selectedProbeId]?.status ?? "idle" @@ -198,7 +208,9 @@ export function ProbeViewer() { ? probeStatus[selectedProbeId]?.error : manifestError; - const probeData = selectedProbeId ? probeCache[selectedProbeId] : undefined; + const probeData = selectedProbeId + ? probeCache[selectedProbeId] + : localProbe?.file; // Only offer the "Show contact IDs" toggle when the probe actually carries them. const hasContactIds = !!probeData?.probes?.[0]?.contact_ids?.length; @@ -263,7 +275,7 @@ export function ProbeViewer() { const lastResetProbeId = useRef(undefined); useEffect(() => { - if (selectedProbeId && lastResetProbeId.current !== selectedProbeId) { + if (activeKey && lastResetProbeId.current !== activeKey) { // Get current view state directly from store (not stale closure value) // This is critical because App.tsx's URL effect may have updated the store // after this component rendered but before this effect runs @@ -272,12 +284,12 @@ export function ProbeViewer() { if (!hasUrlViewState) { resetView(); } - lastResetProbeId.current = selectedProbeId; + lastResetProbeId.current = activeKey; } - if (!selectedProbeId) { + if (!activeKey) { lastResetProbeId.current = undefined; } - }, [selectedProbeId, resetView]); + }, [activeKey, resetView]); // Frame the contacts (ignoring the probe outline) and center on them. Reused // by the default-view effect and the "Full Contacts View" button. @@ -313,8 +325,8 @@ export function ProbeViewer() { const initialUrlHadCameraRef = useRef(searchParams.has("zoom")); const lastDefaultViewProbeId = useRef(undefined); useEffect(() => { - if (!probeData || !selectedProbeId) return; - if (lastDefaultViewProbeId.current === selectedProbeId) return; + if (!probeData || !activeKey) return; + if (lastDefaultViewProbeId.current === activeKey) return; if (canvasSize.width === 0 || canvasSize.height === 0) return; const isFirstProbe = lastDefaultViewProbeId.current === undefined; @@ -322,8 +334,8 @@ export function ProbeViewer() { if (!respectSharedLink) { fullContactsView(); } - lastDefaultViewProbeId.current = selectedProbeId; - }, [probeData, selectedProbeId, canvasSize.width, canvasSize.height, fullContactsView]); + lastDefaultViewProbeId.current = activeKey; + }, [probeData, activeKey, canvasSize.width, canvasSize.height, fullContactsView]); if (manifestStatus === "loading") { return ( @@ -341,6 +353,16 @@ export function ProbeViewer() { ); } + // /local with nothing to show: either a rejected file (the panel reports the + // schema errors) or a cold visit, since local bytes cannot survive a reload. + if (!entry && isLocalRoute) { + return ( +
+ +
+ ); + } + if (!entry) { return (
@@ -356,17 +378,24 @@ export function ProbeViewer() {

{entry.displayName}

{entry.manufacturer} · {entry.contactCount} contacts ·{" "} - {entry.shankCount} shanks ·{" "} - - {JsonIcon} - JSON - + {entry.shankCount} shanks + {/* A locally loaded probe has no GitHub source to link to. */} + {!isLocalProbe && ( + <> + {" "} + ·{" "} + + {JsonIcon} + JSON + + + )}

@@ -388,25 +417,28 @@ export function ProbeViewer() { {DownloadIcon} Export SVG - + {/* A shared link cannot reproduce a local file, so no Share for /local. */} + {!isLocalProbe && ( + + )}
diff --git a/apps/probe-viewer/src/components/Sidebar.tsx b/apps/probe-viewer/src/components/Sidebar.tsx index 91a99cb..9afac9d 100644 --- a/apps/probe-viewer/src/components/Sidebar.tsx +++ b/apps/probe-viewer/src/components/Sidebar.tsx @@ -23,6 +23,7 @@ export function Sidebar() { const selectManufacturer = useAppStore((state) => state.selectManufacturer); const selectedProbeId = useAppStore((state) => state.selectedProbeId); const selectProbe = useAppStore((state) => state.selectProbe); + const clearLocalProbe = useAppStore((state) => state.clearLocalProbe); const navigate = useNavigate(); const searchQuery = useAppStore((state) => state.searchQuery); const setSearchQuery = useAppStore((state) => state.setSearchQuery); @@ -201,6 +202,7 @@ export function Sidebar() { className="sidebar-home" onClick={() => { selectProbe(undefined); + clearLocalProbe(); navigate("/"); }} title="Back to the manufacturer catalog" diff --git a/apps/probe-viewer/src/hooks/useLocalProbeLoader.ts b/apps/probe-viewer/src/hooks/useLocalProbeLoader.ts new file mode 100644 index 0000000..e9f827f --- /dev/null +++ b/apps/probe-viewer/src/hooks/useLocalProbeLoader.ts @@ -0,0 +1,44 @@ +import { useCallback, useRef } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAppStore } from "../state/useAppStore"; + +// Shared plumbing for the two places a user can hand us a file: the catalog +// header (button + drag overlay) and the /local empty state. +// +// Every attempt lands on /local, whether the file was accepted or rejected: a +// compliant file renders there, and a rejected one shows its schema errors in +// the empty state's panel. That keeps /local the single place compliance errors +// are reported, so the catalog needs no error UI of its own. +export function useLocalProbeLoader() { + const loadLocalProbe = useAppStore((state) => state.loadLocalProbe); + const loading = useAppStore((state) => state.localProbeStatus === "loading"); + const navigate = useNavigate(); + const inputRef = useRef(null); + + const loadFile = useCallback( + async (file: File | undefined) => { + if (!file) return; + await loadLocalProbe(file); + navigate("/local"); + }, + [loadLocalProbe, navigate], + ); + + const openPicker = useCallback(() => inputRef.current?.click(), []); + + // Spread onto a hidden . Resetting value on change lets the + // same file be picked again after a rejection. + const fileInputProps = { + ref: inputRef, + type: "file" as const, + accept: "application/json,.json", + className: "local-loader-input", + onChange: (event: React.ChangeEvent) => { + void loadFile(event.target.files?.[0]); + event.target.value = ""; + }, + }; + + return { fileInputProps, loadFile, openPicker, loading }; +} diff --git a/apps/probe-viewer/src/main.tsx b/apps/probe-viewer/src/main.tsx index 4b6c117..009096f 100644 --- a/apps/probe-viewer/src/main.tsx +++ b/apps/probe-viewer/src/main.tsx @@ -29,6 +29,12 @@ const router = createHashRouter([ path: "/probes/:manufacturer/:model", element: , }, + // A probe loaded from the user's own file. It carries no slug because local + // bytes have no shareable URL; the file lives in the store, not the route. + { + path: "/local", + element: , + }, ]); createRoot(document.getElementById("root")!).render( diff --git a/apps/probe-viewer/src/services/localProbe.ts b/apps/probe-viewer/src/services/localProbe.ts new file mode 100644 index 0000000..43a8d28 --- /dev/null +++ b/apps/probe-viewer/src/services/localProbe.ts @@ -0,0 +1,51 @@ +import type { ManifestEntry, ProbeInterfaceFile } from "../types/probe"; + +// Manufacturer label shown for a locally loaded probe. Also used by the viewer +// to recognise a local entry and hide the GitHub/Share affordances that only +// make sense for catalog probes. +export const LOCAL_MANUFACTURER = "Local file"; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .replace(/\.json$/i, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "probe" + ); +} + +// Builds the synthetic manifest entry that drives the viewer header for a +// locally loaded probe. Mirrors the metadata build.py derives for catalog +// probes so the header reads the same (contacts / shanks counts, display name). +export function buildLocalEntry( + file: ProbeInterfaceFile, + fileName: string, +): ManifestEntry { + const probe = file.probes?.[0]; + const annotations = probe?.annotations ?? {}; + const modelName = + typeof annotations.model_name === "string" ? annotations.model_name : undefined; + const displayName = modelName || fileName.replace(/\.json$/i, ""); + const slug = slugify(displayName); + + const contactCount = probe?.contact_positions?.length ?? 0; + const shankCount = new Set(probe?.shank_ids ?? [null]).size; + const numSides = probe?.contact_sides + ? new Set(probe.contact_sides).size + : 1; + const has3dGeometry = probe?.ndim === 3; + + return { + id: `local:${slug}`, + manufacturer: LOCAL_MANUFACTURER, + model: slug, + displayName, + jsonUrl: "", + contactCount, + shankCount, + numSides, + has3dGeometry, + annotations, + }; +} diff --git a/apps/probe-viewer/src/services/validateProbe.ts b/apps/probe-viewer/src/services/validateProbe.ts new file mode 100644 index 0000000..0dd908a --- /dev/null +++ b/apps/probe-viewer/src/services/validateProbe.ts @@ -0,0 +1,48 @@ +import Ajv from "ajv"; +import type { ValidateFunction } from "ajv"; + +// The probeinterface JSON schema is downloaded into the app at build time by +// build.py, from the same upstream source tests.py validates this catalog +// against, so the viewer and CI agree on what "compliant" means. +const SCHEMA_URL = `${import.meta.env.BASE_URL}probe.schema.json`; + +let validatorPromise: Promise | null = null; + +async function getValidator(): Promise { + if (!validatorPromise) { + validatorPromise = (async () => { + const response = await fetch(SCHEMA_URL); + if (!response.ok) { + throw new Error(`Failed to load probe schema (${response.status})`); + } + const schema = await response.json(); + // strict: false because the schema uses constructs ajv's strict mode + // rejects (e.g. the union type in probe_planar_contour), which would throw + // at compile time rather than produce a validation error. + const ajv = new Ajv({ allErrors: true, strict: false }); + return ajv.compile(schema); + })(); + } + return validatorPromise; +} + +export type ProbeValidation = + | { valid: true } + | { valid: false; errors: string[] }; + +// Validates parsed JSON against the probeinterface schema. Returns readable, +// path-annotated messages so the UI can tell the user exactly what makes a file +// non-compliant. +export async function validateProbeFile( + data: unknown, +): Promise { + const validate = await getValidator(); + if (validate(data)) { + return { valid: true }; + } + const errors = (validate.errors ?? []).map((error) => { + const location = error.instancePath || "(root)"; + return `${location} ${error.message ?? "is invalid"}`; + }); + return { valid: false, errors }; +} diff --git a/apps/probe-viewer/src/state/useAppStore.ts b/apps/probe-viewer/src/state/useAppStore.ts index 64c5124..0fcf73e 100644 --- a/apps/probe-viewer/src/state/useAppStore.ts +++ b/apps/probe-viewer/src/state/useAppStore.ts @@ -1,7 +1,9 @@ import { create } from "zustand"; import { fetchManifest } from "../services/manifest"; +import { buildLocalEntry } from "../services/localProbe"; import { fetchProbeData } from "../services/probeLoader"; +import { validateProbeFile } from "../services/validateProbe"; import type { ManifestEntry, ProbeInterfaceFile, ProbeViewerCamera } from "../types/probe"; type LoadStatus = "idle" | "loading" | "success" | "error"; @@ -36,6 +38,12 @@ interface AppState { sideFilter: number | null; probeCache: Record; probeStatus: Record; + // A probe loaded from the user's own file, kept out of the manifest and the + // router: local bytes have no shareable URL, so this lives in its own state + // and renders at /local. undefined until a file is loaded this session. + localProbe?: { entry: ManifestEntry; file: ProbeInterfaceFile }; + localProbeStatus: LoadStatus; + localProbeError?: string; view: ViewState; // false until the camera has been seeded from the URL on load (or there was // nothing to seed). The URL writer holds off until this flips, so it cannot @@ -48,6 +56,11 @@ interface AppState { setSideFilter: (sides: number | null) => void; selectProbe: (probeId?: string) => void; ensureProbeLoaded: (probeId: string) => Promise; + // Reads, parses and schema-validates a user-provided file. On a compliant file + // localProbe is set; otherwise localProbeError carries a readable reason. + // Callers navigate to /local either way, where both outcomes are rendered. + loadLocalProbe: (file: File) => Promise; + clearLocalProbe: () => void; setZoom: (zoom: number) => void; setMaxZoom: (value: number) => void; setViewCenter: (x: number | null, y: number | null) => void; @@ -95,6 +108,9 @@ export const useAppStore = create((set, get) => ({ sideFilter: null, probeCache: {}, probeStatus: {}, + localProbe: undefined, + localProbeStatus: "idle", + localProbeError: undefined, view: INITIAL_VIEW_STATE, cameraInitialized: false, @@ -147,6 +163,55 @@ export const useAppStore = create((set, get) => ({ }; }), + loadLocalProbe: async (file) => { + set({ localProbeStatus: "loading", localProbeError: undefined }); + + let parsed: unknown; + try { + parsed = JSON.parse(await file.text()); + } catch { + set({ + localProbeStatus: "error", + localProbeError: "This file is not valid JSON.", + }); + return; + } + + try { + const result = await validateProbeFile(parsed); + if (!result.valid) { + set({ + localProbeStatus: "error", + localProbeError: `This file is not a compliant probeinterface file:\n${result.errors.join("\n")}`, + }); + return; + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Could not validate the file."; + set({ localProbeStatus: "error", localProbeError: message }); + return; + } + + const probeFile = parsed as ProbeInterfaceFile; + const entry = buildLocalEntry(probeFile, file.name); + set({ + localProbe: { entry, file: probeFile }, + localProbeStatus: "success", + localProbeError: undefined, + // A local probe is not a manifest selection; clear any so the viewer + // resolves to the local probe while at /local. + selectedProbeId: undefined, + }); + }, + + clearLocalProbe: () => + set({ + localProbe: undefined, + localProbeStatus: "idle", + localProbeError: undefined, + }), + ensureProbeLoaded: async (probeId) => { const { probeCache, probeStatus, manifest } = get(); if (probeCache[probeId]) { diff --git a/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts b/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts index 4d830dc..e1b504f 100644 --- a/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts +++ b/apps/probe-viewer/src/state/useRestoreCameraFromUrl.ts @@ -38,7 +38,11 @@ function restoreViewFromParams( // 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() { +// +// `enabled` is false wherever no probe is on screen (the catalog landing), where +// there is no camera for a param to describe and reading one in would only feed +// it back to the writer. +export function useRestoreCameraFromUrl(enabled: boolean) { const [searchParams] = useSearchParams(); const cameraInitialized = useAppStore((state) => state.cameraInitialized); const setZoom = useAppStore((state) => state.setZoom); @@ -49,6 +53,7 @@ export function useRestoreCameraFromUrl() { const markCameraInitialized = useAppStore((state) => state.markCameraInitialized); useEffect(() => { + if (!enabled) return; if (cameraInitialized) return; restoreViewFromParams( searchParams, @@ -60,6 +65,7 @@ export function useRestoreCameraFromUrl() { ); markCameraInitialized(); }, [ + enabled, cameraInitialized, searchParams, setZoom, diff --git a/apps/probe-viewer/src/state/useSyncCameraToUrl.ts b/apps/probe-viewer/src/state/useSyncCameraToUrl.ts index 2e7637e..3294cf3 100644 --- a/apps/probe-viewer/src/state/useSyncCameraToUrl.ts +++ b/apps/probe-viewer/src/state/useSyncCameraToUrl.ts @@ -26,6 +26,21 @@ const FLAG_DEFAULTS: ViewFlags = { showOverview: true, }; +// Every param this hook owns. Listed so they can all be dropped again when no +// probe is in view (see the `enabled` handling below). +const VIEW_PARAM_KEYS = ["zoom", "cx", "cy", "ids", "scale", "overview"]; + +function clearViewParams(setSearchParams: SetURLSearchParams) { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + VIEW_PARAM_KEYS.forEach((key) => next.delete(key)); + return next; + }, + { replace: true }, + ); +} + function setOrDeleteFlag( params: URLSearchParams, key: string, @@ -76,16 +91,30 @@ function writeViewToParams( // 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(); +// +// `enabled` is false wherever no probe is on screen (the catalog landing). There +// the params describe nothing, so this drops them instead of writing: otherwise +// the camera of whichever probe you last viewed rides along on the catalog URL, +// and useRestoreCameraFromUrl reads it straight back in, making the pair +// self-perpetuating and the params impossible to clear. +export function useSyncCameraToUrl(enabled: boolean) { + const [searchParams, 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); + // Guarded on presence so clearing settles after one pass instead of looping: + // once the params are gone this is false and the effect no-ops. + const hasViewParams = VIEW_PARAM_KEYS.some((key) => searchParams.has(key)); + const writeTimeout = useRef | undefined>(undefined); useEffect(() => { + if (!enabled) { + if (hasViewParams) clearViewParams(setSearchParams); + return; + } if (!cameraInitialized) return; clearTimeout(writeTimeout.current); @@ -98,5 +127,14 @@ export function useSyncCameraToUrl() { }, 300); return () => clearTimeout(writeTimeout.current); - }, [cameraInitialized, camera, showContactIds, showScaleBar, showOverview, setSearchParams]); + }, [ + enabled, + hasViewParams, + cameraInitialized, + camera, + showContactIds, + showScaleBar, + showOverview, + setSearchParams, + ]); }