Follow-up to #201 / #203. PR #203 fixed the await-blocking case for Bug A but the harness reveals a residual: camera moves below Cesium's percentageChanged threshold do not fire camera.changed, so the URL write is never even scheduled.
Reproducer
tests/playwright/url_roundtrip_investigation.js against the live deploy (post-#203):
| Iter |
Pan/zoom step |
Status |
| 2 |
small pan only (Δlat ≈ 0.02°, no zoom) |
❌ URL stale |
| 3 |
small pan + zoom |
✅ URL catches up (event fired by zoom) |
Iter 2 specifically: Context A's camera reports lat=35.0150 lng=33.7000 but location.href still has the original lat=34.9957 lng=33.6798. Context B opens the URL and lands at the OLD position.
Mechanism
explorer.qmd:2031 sets viewer.camera.percentageChanged = 0.1, which suppresses camera.changed for sub-threshold moves. A small drag-pan at low altitude doesn't cross the threshold, so the event never fires, so the debounced callback (which contains our URL write — fixed by #203 to happen before any awaits) never runs.
Once a subsequent larger camera move fires camera.changed, the URL catches up to the current camera state — including the previously-uncaptured small pan. So the URL is eventually consistent, but a user who pans-then-immediately-copies sees stale state.
Proposed fix
Add a viewer.camera.moveEnd listener as a parallel URL-write trigger. moveEnd fires once per discrete camera move regardless of magnitude (after mouseup on a drag-pan, after the wheel stops on a zoom). It complements camera.changed — the latter still drives the mode-transition / resolution-load work; moveEnd just makes sure the URL captures every settled camera state.
~3 lines:
viewer.camera.moveEnd.addEventListener(() => {
if (!viewer._suppressHashWrite) {
history.replaceState(null, '', buildHash(viewer));
}
});
The _suppressHashWrite gate already exists and handles the hashchange-flight case correctly (so we don't fight back/forward navigation).
Why a separate PR
#203 was already merged and deployed. This residual is small, targeted, and benefits from independent Codex review.
Acceptance
Follow-up to #201 / #203. PR #203 fixed the await-blocking case for Bug A but the harness reveals a residual: camera moves below Cesium's
percentageChangedthreshold do not firecamera.changed, so the URL write is never even scheduled.Reproducer
tests/playwright/url_roundtrip_investigation.jsagainst the live deploy (post-#203):Iter 2 specifically: Context A's camera reports
lat=35.0150 lng=33.7000butlocation.hrefstill has the originallat=34.9957 lng=33.6798. Context B opens the URL and lands at the OLD position.Mechanism
explorer.qmd:2031setsviewer.camera.percentageChanged = 0.1, which suppressescamera.changedfor sub-threshold moves. A small drag-pan at low altitude doesn't cross the threshold, so the event never fires, so the debounced callback (which contains our URL write — fixed by #203 to happen before any awaits) never runs.Once a subsequent larger camera move fires
camera.changed, the URL catches up to the current camera state — including the previously-uncaptured small pan. So the URL is eventually consistent, but a user who pans-then-immediately-copies sees stale state.Proposed fix
Add a
viewer.camera.moveEndlistener as a parallel URL-write trigger.moveEndfires once per discrete camera move regardless of magnitude (after mouseup on a drag-pan, after the wheel stops on a zoom). It complementscamera.changed— the latter still drives the mode-transition / resolution-load work;moveEndjust makes sure the URL captures every settled camera state.~3 lines:
The
_suppressHashWritegate already exists and handles the hashchange-flight case correctly (so we don't fight back/forward navigation).Why a separate PR
#203 was already merged and deployed. This residual is small, targeted, and benefits from independent Codex review.
Acceptance
url_roundtrip_investigation.jspass against the deployed site.