+ );
+}
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,
+ ]);
}