explorer: fix URL state round-trip (closes #201 parts A+B)#203
Conversation
Bug A: camera-handler URL write was deferred behind the resolution-load and point-mode-transition awaits inside the debounced callback. When a user panned into a region that triggered cluster→res8 or cold-cache point-mode (60–90s on first load per isamplesorg#190), the URL didn't update until those awaits settled — and a URL copy in the meantime captured the PREVIOUS camera state. Reproduced empirically: iter 2 of the harness moved the camera but `location.href` stayed at the original deep-link. Fix: write the URL hash via `history.replaceState` at the START of the debounced callback, before any awaits. The camera position/heading is known synchronously. The existing trailing write is kept so any mode change produced by the awaits still gets captured. Bug B: deep-link `mode=point` was silently ignored when the URL omitted `heading` (i.e. heading at default 0). The boot path positions the camera via `camera.setView`, which in some cases does not raise `camera.changed`, so the camera-handler that drives the cluster→point transition never runs. Reproduced: iters 3 and 6 of the harness (URLs without `heading=`) failed to enter point mode in a fresh tab; iters 4 and 5 (with non-zero heading) succeeded. Fix: explicit `tryEnterPointModeIfNeeded()` call at the end of the deep-link boot path when `ih.mode === 'point'`. The helper short-circuits if alt >= ENTER_POINT_ALT or we're already in point mode, so this is a no-op for cluster deep-links. Verification: tests/playwright/url_roundtrip_investigation.js, run against localhost with these fixes — all 6 iterations now pass (cf. 4 failures against the live site pre-fix). Harness is shipped under tests/playwright/ as a regression artifact and starting point for a proper spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#203 fixed the await-blocking case for Bug A but a residual remained: small camera moves below Cesium's `percentageChanged` threshold (set to 0.1 at line 2043) don't fire `camera.changed`, so the debounced callback containing the URL write never runs. A user who pans a small distance and immediately copies the URL gets stale state. Empirically confirmed via `url_roundtrip_investigation.js` against the post-#203 deploy: iter 2 (Δlat ≈ 0.02° pan-only, no zoom) had Context A at the new camera position but `location.href` still at the original deep-link, so Context B opened the stale URL. Fix: add `viewer.camera.moveEnd` as a parallel URL-write trigger. `moveEnd` fires once per discrete settled camera move regardless of magnitude (mouseup on drag-pan, wheel-stop on zoom, flight-complete on flyTo), so every position the user can reach gets captured. Keeps `camera.changed` for its existing role (mode transitions, resolution loads); just adds a lightweight URL-only backstop. The `_suppressHashWrite` gate already handles the hashchange-flight path correctly, so no fight with back/forward navigation. Verification: harness now passes all 6 iterations against localhost with this fix (post-#203 it failed on iter 2). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retrospective Codex review (2026-05-11)Posted on behalf of OpenAI Codex (gpt-5.4). Run after merge + deploy of #203 + #205. Codex read both diffs and surrounding context in Overall: these PRs fixed the two observed round-trip failures in the right general direction, but the implementation is tactical and still leaves URL/state synchronization more fragile than I'd like. I would not revert it. I would file follow-ups. Correctness critiqueThe early URL write in the The caveat: the early write captures the current mode, not the intended target mode. If the user crosses below
The boot-end
Programmatic Design critique
However, #205 only uses The early-write pattern is understandable but not a healthy long-term shape. There are now many URL writers: selection clicks, mode transitions, source/facet query writers, share button, camera debounce early/trailing writes, and writeGlobeHash({ replace: true })
reconcileCameraState({ reason, writeEarlyHash: true })
setExplorerMode(nextMode, { pushHistory: false | true })Then boot, hashchange, user camera movement, and share/copy can call clear, intention-revealing paths. For Bug B, I would not try to force Cesium to emit What we missedThe harness tested camera + mode, but not the full URL contract. Untested or suspicious cases:
The mode-sync anomaly is worth filing. The code currently has two mode stores: closure-private The investigation script should be promoted, but not necessarily as-is. Keep the artifact, then extract CI-safe specs:
The full cold-cache/live-site investigation can remain a manual diagnostic because it is slow and network-sensitive. Suggested follow-up issues
|
Closes #201 (parts A + B). Part 1 — the misleading 'Samples in View' counter — is left for a separate PR.
What this changes
Two small, independent fixes in
explorer.qmd:Bug A — camera-handler URL write was deferred behind awaits
explorer.qmd:1965-2030— the camera-changed handler's debounced callback awaitsloadRes/tryEnterPointModeIfNeeded(cold-cache point-mode boot can be 60–90s per #190) before reaching the trailinghistory.replaceState. When a user pans into a region that triggers a resolution change or point-mode transition, the URL write is deferred until those awaits settle. A URL copy in the meantime captures the previous camera state.Fix: write the URL hash at the START of the debounced callback, before any awaits. The camera state is known synchronously. The trailing write is kept so any mode change produced by the awaits still gets captured.
Bug B — deep-link
mode=pointsilently ignored when heading=0explorer.qmdboot path positions the camera viacamera.setView, which in some cases (notably when the URL omitsheading=and it defaults to 0) does not raisecamera.changed. The camera-handler that drives the cluster→point transition therefore never runs, and the URL'smode=pointis ignored.Fix: explicit
tryEnterPointModeIfNeeded()call at the end of the deep-link boot path whenih.mode === 'point'. The helper short-circuits if alt >= ENTER_POINT_ALT or we're already in point mode, so this is a no-op for cluster deep-links.Verification
tests/playwright/url_roundtrip_investigation.jsis the harness that originally detected the bugs (see #201 comment with empirical evidence). It drives Context A through 6 deterministic pan/zoom steps from the Cyprus deep-link, then opens each resulting URL in a fresh Context B and probes at 5s/15s/30s.Harness logs land at
/tmp/url_roundtrip_log.json. Run withTEST_URL=http://localhost:5860/explorer.html node tests/playwright/url_roundtrip_investigation.js.Risks
replaceState(no history-entry growth). If the awaits don't change mode, the second write is identical to the first. Acceptable cost.tryEnterPointModeIfNeeded()being safe to call from boot. Reviewed: it short-circuits onmode === 'point'and on alt >= ENTER_POINT_ALT, and uses the sameloadRespath that the camera handler would. Functionally equivalent to "what would happen ifcamera.changedfired."setView-doesn't-fire-camera.changed is the underlying cause of Bug B's mechanism. Documented inline at the boot call site. This PR works around it; a longer-term fix would be to ensuresetViewraises the event (likely by tweaking the call shape or following with a no-op camera move).tests/playwright/but is a Node script, not a Playwright spec. A follow-up should adapt it into a proper*.spec.jsso it runs in CI. Tracked as a TODO in the harness file's header comment.Test plan
🤖 Generated with Claude Code