From 7cfdc472555b852167c7634dcdb6314ae43f1886 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 21:49:57 -0700 Subject: [PATCH 1/4] =?UTF-8?q?explorer:=20surface=20'Fetching=20sample=20?= =?UTF-8?q?index=E2=80=A6'=20during=20boot=E2=86=92point-mode=20wait=20(#1?= =?UTF-8?q?90=20fix=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a cold-cache deep-link to a point-mode altitude, DuckDB-WASM 1.24.0 falls back to a full HTTP read of samples_map_lite.parquet (~60 MB) and the res8 cluster file, blocking sample dots for 60-90s. Today the only phase-message signal during that wait is the internal "Loading H3 res8..." string, followed by a misleading "Zoom closer for individual samples." flash before point mode finally fires "Loading individual samples...". Fix the user-facing signal independently of the version bump (issue #190 fix 1, blocked on UBIGINT representation changes in newer wasm builds): - loadRes() now accepts opts.loadingMsg / opts.suppressDoneMsg so callers can override the default phase text and skip the intermediate done message when the next step will overwrite it anyway. - Camera handler's cluster→point branch passes loadingMsg='Fetching sample index…' and suppressDoneMsg=true, so the cold-cache wait shows one coherent message instead of three transient ones. Other loadRes callers (sourceFilter handler, cluster-mode resolution changes) keep the original "Loading H3 res\${res}..." behavior unchanged. Refs #190. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index 0566aa0..ed9af98 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -1350,11 +1350,16 @@ zoomWatcher = { let cachedData = null; // array of rows // --- H3 cluster loading (existing logic) --- + // + // `opts.loadingMsg` overrides the default "Loading H3 res${res}..." phase + // text. Used by the boot→point-mode path so the user sees a coherent + // "Fetching sample index…" message during the cold-cache wait (issue #190 + // fix 2) instead of internal "H3 res8" jargon. let loadResGen = 0; // generation counter to discard stale results - const loadRes = async (res, url) => { + const loadRes = async (res, url, opts = {}) => { const gen = ++loadResGen; // claim a generation loading = true; - updatePhaseMsg(`Loading H3 res${res}...`, 'loading'); + updatePhaseMsg(opts.loadingMsg || `Loading H3 res${res}...`, 'loading'); try { performance.mark(`r${res}-s`); @@ -1398,7 +1403,15 @@ zoomWatcher = { const bounds = getViewportBounds(); const inView = countInViewport(bounds); updateStats(`H3 Res${res}`, `${inView.clusters.toLocaleString()} / ${data.length.toLocaleString()}`, inView.samples.toLocaleString(), `${(elapsed/1000).toFixed(1)}s`, 'Clusters in View / Loaded', 'Samples in View'); - updatePhaseMsg(`${data.length.toLocaleString()} clusters, ${total.toLocaleString()} samples. ${res < 8 ? 'Zoom in for finer detail.' : 'Zoom closer for individual samples.'}`, 'done'); + // Skip the "Zoom closer for individual samples." done message when + // the caller is about to transition into point mode itself — the + // next step (loadViewportSamples) immediately overwrites it with + // its own "Loading individual samples…" loading state, so flashing + // a misleading "zoom closer" hint at a user who is already deep + // in point altitude is just noise (issue #190 fix 2). + if (!opts.suppressDoneMsg) { + updatePhaseMsg(`${data.length.toLocaleString()} clusters, ${total.toLocaleString()} samples. ${res < 8 ? 'Zoom in for finer detail.' : 'Zoom closer for individual samples.'}`, 'done'); + } currentRes = res; console.log(`Res${res}: ${data.length} clusters in ${elapsed.toFixed(0)}ms`); @@ -1833,9 +1846,23 @@ zoomWatcher = { : mode; if (targetMode === 'point' && mode !== 'point') { - // Make sure we're at res8 clusters before transitioning + // Make sure we're at res8 clusters before transitioning. + // + // On a cold-cache deep-link to a point-mode altitude, this + // res8 fetch can take 60-90s while the 60 MB samples_map_lite + // file is also being pulled (DuckDB-WASM 1.24.0 falls back to + // a full HTTP read; see issue #190). During that wait the + // user sees the empty globe and no sample dots. Surface a + // boot-aware loading message so the user knows the page is + // busy fetching the index, not broken — and suppress the + // intermediate "Zoom closer for individual samples." done + // message that would otherwise flash before point mode + // overwrites it with its own "Loading individual samples…". if (currentRes !== 8 && !loading) { - await loadRes(8, h3_res8_url); + await loadRes(8, h3_res8_url, { + loadingMsg: 'Fetching sample index…', + suppressDoneMsg: true, + }); } enterPointMode(); } else if (targetMode === 'cluster' && mode !== 'cluster') { From e0f70efe9569254240621ad662d91056c5db322e Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sun, 10 May 2026 05:58:43 -0700 Subject: [PATCH 2/4] explorer: harden loadRes return contract + post-await re-checks (review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex review of #191: 1. loadRes now returns true only when it applied fresh data; false on stale-generation supersession or caught failure. Camera handler's cluster→point branch uses the return value to gate enterPointMode() instead of treating a normal `await` return as success — fixes the pre-existing race where a source-filter change during the 60-90s cold-cache wait can supersede the res8 load (currentRes is still 4 at that point) and the camera handler would otherwise enter point mode with res8 not actually loaded. 2. Add matching generation guard in loadRes's catch block — a stale failure must not overwrite UI state owned by a newer in-flight call. Same shape applied to the `finally` so a stale call's cleanup can't clear `loading` while a newer load is still in flight. 3. Add opts.errorMsg so the camera-handler boot path can surface "Failed to fetch the sample index — try zooming out and back in." instead of the internal "Failed to load H3 res8 — try zooming again." Coherent with the new "Fetching sample index…" loading copy. 4. Camera handler also re-checks altitude (user may have zoomed back out during the wait) and `mode` (another path may have already entered point mode) before enterPointMode(). Also widen the inline comment to acknowledge the override messages apply to any cluster→point transition where currentRes !== 8, not just cold-cache boot — intentional, the copy reads sensibly for both. Refs #190. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 62 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index ed9af98..abce64a 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -1351,10 +1351,17 @@ zoomWatcher = { // --- H3 cluster loading (existing logic) --- // - // `opts.loadingMsg` overrides the default "Loading H3 res${res}..." phase - // text. Used by the boot→point-mode path so the user sees a coherent - // "Fetching sample index…" message during the cold-cache wait (issue #190 - // fix 2) instead of internal "H3 res8" jargon. + // `opts.loadingMsg` / `opts.errorMsg` override the default phase text so + // callers like the boot→point-mode path can show a coherent + // "Fetching sample index…" / "Failed to fetch the sample index…" pair + // (issue #190 fix 2) instead of internal "H3 res8" jargon. + // + // Return value: `true` if this call applied fresh data and `currentRes` + // is now `res`; `false` if the call was superseded by a newer one (stale + // generation) or failed. Callers that gate follow-up work on the cluster + // resolution being ready (e.g. the camera handler before `enterPointMode`) + // must use the return value rather than treating a normal `await` return + // as success. let loadResGen = 0; // generation counter to discard stale results const loadRes = async (res, url, opts = {}) => { const gen = ++loadResGen; // claim a generation @@ -1371,7 +1378,7 @@ zoomWatcher = { WHERE 1=1${sourceFilterSQL('dominant_source')} `); - if (gen !== loadResGen) return; // stale — a newer call superseded this one + if (gen !== loadResGen) return false; // stale — a newer call superseded this one viewer.h3Points.removeAll(); const scalar = new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.3); let total = 0; @@ -1415,11 +1422,20 @@ zoomWatcher = { currentRes = res; console.log(`Res${res}: ${data.length} clusters in ${elapsed.toFixed(0)}ms`); + return true; } catch(err) { + // Same generation guard as the success path — a stale failure + // must not overwrite UI state owned by a newer in-flight call. + if (gen !== loadResGen) return false; console.error(`Failed to load res${res}:`, err); - updatePhaseMsg(`Failed to load H3 res${res} — try zooming again.`, 'loading'); + updatePhaseMsg(opts.errorMsg || `Failed to load H3 res${res} — try zooming again.`, 'loading'); + return false; } finally { - loading = false; + // Only release the busy flag if we're still the current generation. + // A stale call's `finally` running after a newer call has set + // `loading = true` would otherwise clear the flag while the newer + // load is still in flight. + if (gen === loadResGen) loading = false; } }; @@ -1853,18 +1869,38 @@ zoomWatcher = { // file is also being pulled (DuckDB-WASM 1.24.0 falls back to // a full HTTP read; see issue #190). During that wait the // user sees the empty globe and no sample dots. Surface a - // boot-aware loading message so the user knows the page is - // busy fetching the index, not broken — and suppress the - // intermediate "Zoom closer for individual samples." done + // boot-aware loading/error message pair so the user knows the + // page is busy fetching the index, not broken — and suppress + // the intermediate "Zoom closer for individual samples." done // message that would otherwise flash before point mode // overwrites it with its own "Loading individual samples…". - if (currentRes !== 8 && !loading) { - await loadRes(8, h3_res8_url, { + // + // Note: the override messages apply to any cluster→point + // transition where `currentRes !== 8`, including manual + // zoom-in, not only cold-cache boot. That's intentional — + // "Fetching sample index…" reads sensibly for both. + let res8Ready = currentRes === 8; + if (!res8Ready && !loading) { + res8Ready = await loadRes(8, h3_res8_url, { loadingMsg: 'Fetching sample index…', suppressDoneMsg: true, + errorMsg: 'Failed to fetch the sample index — try zooming out and back in.', }); } - enterPointMode(); + // Re-check preconditions after the (potentially 60-90s) await: + // - `res8Ready` is false if our load was superseded by a newer + // `loadRes` (e.g. a source-filter change that fired + // `loadRes(currentRes=4, ...)` while we were still waiting) + // or if it failed. In either case `currentRes` may not be 8 + // and entering point mode would render dots over the wrong + // cluster index. + // - The user may have zoomed back out during the wait, so + // altitude is no longer in the point-mode regime. + // - Another path may have already entered point mode. + const hNow = viewer.camera.positionCartographic.height; + if (res8Ready && mode !== 'point' && hNow < ENTER_POINT_ALT) { + enterPointMode(); + } } else if (targetMode === 'cluster' && mode !== 'cluster') { exitPointMode(); // Reload appropriate resolution From 1f109e09c1fa41c825436f605fd485b0f13125ff Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sun, 10 May 2026 06:21:04 -0700 Subject: [PATCH 3/4] =?UTF-8?q?explorer:=20factor=20tryEnterPointModeIfNee?= =?UTF-8?q?ded()=20=E2=80=94=20close=20supersession=20liveness=20gap=20(re?= =?UTF-8?q?view=20round=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex round-2 residual finding: in round 2, the camera handler correctly refused to call enterPointMode() when its awaited res8 load came back stale (false). But in the source-filter supersession case, no camera event necessarily fires afterwards, so the user could end up stranded — camera below ENTER_POINT_ALT, mode === 'cluster', currentRes === 4, loading === false — until they nudged the camera. Factor the cluster→point transition into an idempotent tryEnterPointModeIfNeeded() helper. Two callers: 1. Camera handler's `targetMode === 'point' && mode !== 'point'` branch — the normal cold-cache deep-link path. 2. Source-filter handler's `mode === 'cluster'` branch, immediately after its own loadRes(currentRes, ...) settles — drives the cluster→point transition forward when the user toggled the filter mid-way through the boot wait. Helper short-circuits if already in point mode or above ENTER_POINT_ALT, and re-checks both after its own (potentially long) res8 await for the same reasons round 2 already handled (zoom-back-out during wait, another path entered point mode first). Net change: 1 new ~20-line helper, camera-handler block shrinks by ~30 lines (delegated to helper), source-filter handler gains a single `await tryEnterPointModeIfNeeded()` call. Refs #190. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 93 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 54 insertions(+), 39 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index abce64a..cc69e4d 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -1604,6 +1604,44 @@ zoomWatcher = { console.log('Exited point mode'); } + // --- Boot→point-mode transition (issue #190 fix 2) --- + // + // Idempotent helper that runs the cluster→point-mode transition iff the + // camera is currently at point-mode altitude. Called from two sites: + // + // 1. The camera-changed handler, when `targetMode === 'point' && mode + // !== 'point'` — the normal cold-cache deep-link path. + // 2. The source-filter handler's `mode === 'cluster'` branch, after its + // own `loadRes(currentRes, ...)` settles. Without this second call, + // a source-filter toggle during the 60-90s cold-cache wait would + // supersede the camera handler's pending res8 load (`loadResGen`++), + // the camera handler's post-await re-check would correctly refuse + // to enter point mode, and then no camera event would necessarily + // fire to retry — leaving the user in cluster mode at point altitude + // until they nudged the camera (issue #190 round-2 review). + // + // The function re-checks altitude/`mode` after its own (potentially + // long) `loadRes` await for the same reason: if the user zooms back out + // or another path enters point mode during the wait, we must not force + // entry afterwards. + async function tryEnterPointModeIfNeeded() { + if (mode === 'point') return; + if (viewer.camera.positionCartographic.height >= ENTER_POINT_ALT) return; + + let res8Ready = currentRes === 8; + if (!res8Ready && !loading) { + res8Ready = await loadRes(8, h3_res8_url, { + loadingMsg: 'Fetching sample index…', + suppressDoneMsg: true, + errorMsg: 'Failed to fetch the sample index — try zooming out and back in.', + }); + } + const hNow = viewer.camera.positionCartographic.height; + if (res8Ready && mode !== 'point' && hNow < ENTER_POINT_ALT) { + enterPointMode(); + } + } + // === Cross-filter facet count refresh (issue #156, Phase 2) === // // Counts answer: for each value in facet D, how many samples would match @@ -1770,6 +1808,16 @@ zoomWatcher = { if (mode === 'cluster') { loading = false; await loadRes(currentRes, resUrls[currentRes]); + // Liveness recovery (issue #190 round-2 review): if the user + // is sitting at point-mode altitude — e.g. they toggled the + // source filter mid-way through the cold-cache boot wait, + // which superseded the camera handler's pending res8 load — + // drive the cluster→point transition forward here. Without + // this, the user would stay in cluster mode at point altitude + // until they nudged the camera. The helper is idempotent and + // returns immediately if already in point mode or above the + // point-mode altitude threshold. + await tryEnterPointModeIfNeeded(); } else { cachedBounds = null; await loadViewportSamples(); @@ -1862,45 +1910,12 @@ zoomWatcher = { : mode; if (targetMode === 'point' && mode !== 'point') { - // Make sure we're at res8 clusters before transitioning. - // - // On a cold-cache deep-link to a point-mode altitude, this - // res8 fetch can take 60-90s while the 60 MB samples_map_lite - // file is also being pulled (DuckDB-WASM 1.24.0 falls back to - // a full HTTP read; see issue #190). During that wait the - // user sees the empty globe and no sample dots. Surface a - // boot-aware loading/error message pair so the user knows the - // page is busy fetching the index, not broken — and suppress - // the intermediate "Zoom closer for individual samples." done - // message that would otherwise flash before point mode - // overwrites it with its own "Loading individual samples…". - // - // Note: the override messages apply to any cluster→point - // transition where `currentRes !== 8`, including manual - // zoom-in, not only cold-cache boot. That's intentional — - // "Fetching sample index…" reads sensibly for both. - let res8Ready = currentRes === 8; - if (!res8Ready && !loading) { - res8Ready = await loadRes(8, h3_res8_url, { - loadingMsg: 'Fetching sample index…', - suppressDoneMsg: true, - errorMsg: 'Failed to fetch the sample index — try zooming out and back in.', - }); - } - // Re-check preconditions after the (potentially 60-90s) await: - // - `res8Ready` is false if our load was superseded by a newer - // `loadRes` (e.g. a source-filter change that fired - // `loadRes(currentRes=4, ...)` while we were still waiting) - // or if it failed. In either case `currentRes` may not be 8 - // and entering point mode would render dots over the wrong - // cluster index. - // - The user may have zoomed back out during the wait, so - // altitude is no longer in the point-mode regime. - // - Another path may have already entered point mode. - const hNow = viewer.camera.positionCartographic.height; - if (res8Ready && mode !== 'point' && hNow < ENTER_POINT_ALT) { - enterPointMode(); - } + // Cold-cache deep-link: the res8 + samples_map_lite fetches + // can take 60-90s (DuckDB-WASM 1.24.0 falls back to a full + // HTTP read; see issue #190). Delegate to the shared helper + // so the source-filter handler can call the same path on + // supersession recovery. + await tryEnterPointModeIfNeeded(); } else if (targetMode === 'cluster' && mode !== 'cluster') { exitPointMode(); // Reload appropriate resolution From 5bb5a7818e0c5478614b7544b5dd4ed53c88deca Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sun, 10 May 2026 06:43:18 -0700 Subject: [PATCH 4/4] explorer: reconcile point mode after cluster reloads --- explorer.qmd | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/explorer.qmd b/explorer.qmd index cc69e4d..99b8c21 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -1607,7 +1607,7 @@ zoomWatcher = { // --- Boot→point-mode transition (issue #190 fix 2) --- // // Idempotent helper that runs the cluster→point-mode transition iff the - // camera is currently at point-mode altitude. Called from two sites: + // camera is currently at point-mode altitude. Called from three paths: // // 1. The camera-changed handler, when `targetMode === 'point' && mode // !== 'point'` — the normal cold-cache deep-link path. @@ -1619,6 +1619,10 @@ zoomWatcher = { // to enter point mode, and then no camera event would necessarily // fire to retry — leaving the user in cluster mode at point altitude // until they nudged the camera (issue #190 round-2 review). + // 3. The camera handler's cluster-resolution reload branches, after + // their `loadRes(target, ...)` settles. This covers the same liveness + // shape when a camera-initiated cluster load was already in flight as + // the user crossed into point altitude. // // The function re-checks altitude/`mode` after its own (potentially // long) `loadRes` await for the same reason: if the user zooms back out @@ -1922,6 +1926,10 @@ zoomWatcher = { const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8; if (target !== currentRes && !loading) { await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]); + // The user may have crossed below ENTER_POINT_ALT while + // this cluster load was in flight; reconcile after it + // settles so no extra camera nudge is required. + await tryEnterPointModeIfNeeded(); } } else if (targetMode === 'point') { // Already in point mode — update viewport samples @@ -1931,6 +1939,10 @@ zoomWatcher = { const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8; if (target !== currentRes && !loading) { await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]); + // The user may have crossed below ENTER_POINT_ALT while + // this cluster load was in flight; reconcile after it + // settles so no extra camera nudge is required. + await tryEnterPointModeIfNeeded(); } }