From 25f060d2436a0ed7adc590f4ed01ca84c70b8415 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 09:40:33 -0700 Subject: [PATCH 1/7] explorer: persist selected cluster identity in URL via &h3= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of EXPLORER_CLUSTER_URL_PROPOSAL.md (#182). Cluster selection now round-trips through the URL hash, complementing the existing &pid= for samples. Use case: share or bookmark a specific cluster you clicked, and have collaborators land on the same H3 cell with side-panel populated. Encoding: #h3=843f6d3ffffffff H3 cell index in canonical 15-char hex (no 0x prefix). The cell index encodes its own resolution; no separate &res= or &cluster_source= field needed. The existing &sources= filter (already URL-persisted) covers the only filter that affects cluster aggregation — material/context/object_type filters can't, per the comment at :1706-1710. Mechanics: - h3_cell carried into the runtime cluster .id at both add() sites (:992, :1335) as a hex string via row.h3_cell.toString(16). The parquet column is UBIGINT; converting to hex once at ingestion keeps the URL representation canonical. - _globeState.selectedH3 added; mutated by cluster-click (:923) and cleared by sample-click (:895) for mutual exclusion. Same pattern as selectedPid. - readHash parses h3 (:626); buildHash emits h3 when set (:645). - fetchClusterByH3 helper at :1791 looks up the row across res4/res6/res8 parquets via UNION ALL. DuckDB-WASM doesn't accept 0x... literals, so hex is converted to decimal in JS via BigInt and CAST AS UBIGINT in SQL. - hydrateClusterUI helper at :1827 mirrors the cluster-click side-panel + nearby-samples query, called from both the boot deep-link (:2266) and the back/forward hashchange handler (:1899). - Mutual-exclusion at hydration time: &pid= wins if both are present, per the proposal §4. EXPLORER_STATE.md §2 updated with the new h3 row. Verified locally: - URL #h3=843f6d3ffffffff (a known res4 cell with 151,334 OpenContext samples in central Turkey) round-trips: side panel shows 'Selected Cluster / OpenContext / H3 res4 / 151,334 samples / 37.6619, 32.8334' with 30 nearby samples loaded. - Empty hash + hash without h3/pid both load without errors. Closes Phase 1 of #182. Phase 2 (unified &sel=) deferred unless a third selection type appears. Co-Authored-By: Claude Opus 4.7 (1M context) --- EXPLORER_STATE.md | 1 + explorer.qmd | 91 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/EXPLORER_STATE.md b/EXPLORER_STATE.md index 21c8415..58116a8 100644 --- a/EXPLORER_STATE.md +++ b/EXPLORER_STATE.md @@ -54,6 +54,7 @@ The hash is the camera/deep-link channel. Always written together by | `pitch` | `viewer.camera.pitch` | `-90` | degrees, 1-decimal | same | same; only written if `\|pitch + 90\| > 1` (`:608`) | clamped `[-90, 0]` (`:591`) | | | `mode` | `viewer._globeState.mode` | omitted (= `cluster`) | `point` only | `readHash()` (`:592`); applied after camera flight in `hashchange` handler (`:1727-1729`); also restored from `_initialHash` after zoomWatcher init | `buildHash` only writes if `'point'` (`:610`); push triggers as above | exact-match `'point'` | absence ⇒ cluster | | `pid` | `viewer._globeState.selectedPid` | omitted | sample pid string (URL-encoded) | `readHash()` (`:593`); applied at end of `zoomWatcher` (`:1873-1901`) and on `hashchange` (`:1733-1756`) | sample-click sets it (`:860`); cluster-click clears it (`:887`); written in `buildHash` if non-null (`:611`) | none beyond `null` check | drives a `lite_url` lookup + lazy `wide_url` description fetch | +| `h3` | `viewer._globeState.selectedH3` | omitted | H3 cell index hex string (e.g. `841a067ffffffff`) | `readHash()` parses; boot deep-link calls `fetchClusterByH3` then `hydrateClusterUI`; same path on `hashchange` | cluster-click sets `selectedH3 = meta.h3_cell` and clears `selectedPid` (mutual exclusion); sample-click clears `selectedH3`; written in `buildHash` if non-null | regex `[^0-9a-fA-F]` strip; null check | drives a `WHERE h3_cell = ?` lookup against `h3_res4_url` / `h3_res6_url` / `h3_res8_url` (UNION ALL, LIMIT 1). `&pid=` wins if both present. Per `EXPLORER_CLUSTER_URL_PROPOSAL.md` | ### Hash write-vs-read coordination diff --git a/explorer.qmd b/explorer.qmd index 8571bf6..5d87465 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -623,6 +623,7 @@ function readHash() { pitch: parseNum(params.get('pitch'), -90, -90, 0), mode: params.get('mode') || null, pid: params.get('pid') || null, + h3: params.get('h3') || null, }; } @@ -641,6 +642,7 @@ function buildHash(v) { const gs = v._globeState; if (gs.mode === 'point') params.set('mode', 'point'); if (gs.selectedPid) params.set('pid', gs.selectedPid); + if (gs.selectedH3) params.set('h3', gs.selectedH3); return '#' + params.toString(); } @@ -818,7 +820,7 @@ viewer = { }); // URL deep-link state (must be set before globalRect/once block reads it) - v._globeState = { mode: 'cluster', selectedPid: null }; + v._globeState = { mode: 'cluster', selectedPid: null, selectedH3: null }; v._initialHash = readHash(); v._suppressHashWrite = true; // cleared after zoomWatcher initializes v._suppressTimer = null; @@ -890,6 +892,7 @@ viewer = { // --- Individual sample click --- updateSampleCard(meta); v._globeState.selectedPid = meta.pid; + v._globeState.selectedH3 = null; history.pushState(null, '', buildHash(v)); // Clear nearby list const sampEl = document.getElementById('samplesSection'); @@ -917,6 +920,7 @@ viewer = { // --- Cluster click --- updateClusterCard(meta); v._globeState.selectedPid = null; + v._globeState.selectedH3 = meta.h3_cell || null; history.pushState(null, '', buildHash(v)); const sampEl = document.getElementById('samplesSection'); @@ -984,7 +988,8 @@ phase1 = { totalSamples += count; const size = Math.min(3 + Math.log10(count) * 4, 20); viewer.h3Points.add({ - id: { count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: 4 }, + // h3_cell is UBIGINT in the parquet; carry as hex string (canonical H3 form). + id: { h3_cell: row.h3_cell.toString(16), count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: 4 }, position: Cesium.Cartesian3.fromDegrees(row.center_lng, row.center_lat, 0), pixelSize: size, color: Cesium.Color.fromCssColorString(SOURCE_COLORS[row.dominant_source] || '#666').withAlpha(0.8), @@ -1327,7 +1332,7 @@ zoomWatcher = { total += row.sample_count; const size = Math.min(3 + Math.log10(row.sample_count) * 3.5, 18); viewer.h3Points.add({ - id: { count: row.sample_count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: res }, + id: { h3_cell: row.h3_cell.toString(16), count: row.sample_count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: res }, position: Cesium.Cartesian3.fromDegrees(row.center_lng, row.center_lat, 0), pixelSize: size, color: Cesium.Color.fromCssColorString(SOURCE_COLORS[row.dominant_source] || '#666').withAlpha(0.85), @@ -1780,6 +1785,71 @@ zoomWatcher = { }); viewer.camera.percentageChanged = 0.1; + // --- Helper: hydrate cluster row from H3 cell index across all 3 resolution parquets --- + // Used by both the boot deep-link path and the back/forward hashchange handler. + // The cell only exists in one of res4/res6/res8; UNION ALL + LIMIT 1 picks it. + async function fetchClusterByH3(cellHex) { + // h3_cell is stored as UBIGINT in the parquet; we receive a hex string + // (canonical H3 form). DuckDB-WASM doesn't accept `0x...` literals, so + // convert hex → decimal in JS via BigInt, then CAST to UBIGINT in SQL. + const safe = String(cellHex).replace(/[^0-9a-fA-F]/g, ''); + if (!safe) return null; + let decimal; + try { decimal = BigInt('0x' + safe).toString(); } + catch { return null; } + try { + const result = await db.query(` + SELECT 4 AS resolution, h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count + FROM read_parquet('${h3_res4_url}') WHERE h3_cell = CAST('${decimal}' AS UBIGINT) + UNION ALL + SELECT 6 AS resolution, h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count + FROM read_parquet('${h3_res6_url}') WHERE h3_cell = CAST('${decimal}' AS UBIGINT) + UNION ALL + SELECT 8 AS resolution, h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count + FROM read_parquet('${h3_res8_url}') WHERE h3_cell = CAST('${decimal}' AS UBIGINT) + LIMIT 1 + `); + if (!result || result.length === 0) return null; + const r = result[0]; + return { + h3_cell: r.h3_cell.toString(16), + count: r.sample_count, + source: r.dominant_source, + lat: r.center_lat, + lng: r.center_lng, + resolution: r.resolution, + }; + } catch(err) { + console.error("fetchClusterByH3 query failed:", err); + return null; + } + } + + // --- Helper: populate side panel + nearby-samples list from a cluster meta object --- + // Mirrors the cluster-click handler at the LEFT_CLICK input action above. + async function hydrateClusterUI(meta) { + if (!meta) return; + updateClusterCard(meta); + const sampEl = document.getElementById('samplesSection'); + if (sampEl) sampEl.innerHTML = '
Loading nearby samples...
'; + const delta = meta.resolution === 4 ? 2.0 : meta.resolution === 6 ? 0.5 : 0.1; + try { + const samples = await db.query(` + SELECT pid, label, source, latitude, longitude, place_name + FROM read_parquet('${lite_url}') + WHERE latitude BETWEEN ${meta.lat - delta} AND ${meta.lat + delta} + AND longitude BETWEEN ${meta.lng - delta} AND ${meta.lng + delta} + ${sourceFilterSQL('source')} + ${facetFilterSQL()} + LIMIT 30 + `); + updateSamples(samples); + } catch(err) { + console.error("Cluster hydration nearby query failed:", err); + if (sampEl) sampEl.innerHTML = '
Query failed — try again.
'; + } + } + // --- Handle browser back/forward --- window.addEventListener('hashchange', async () => { const state = readHash(); @@ -1805,9 +1875,10 @@ zoomWatcher = { else if (s.mode !== 'point' && mode === 'point') exitPointMode(false); }, 2000); - // Handle pid selection + // Handle pid selection (sample mode wins if both present — see EXPLORER_CLUSTER_URL_PROPOSAL §4) if (state.pid) { viewer._globeState.selectedPid = state.pid; + viewer._globeState.selectedH3 = null; try { const sample = await db.query(` SELECT pid, label, source, latitude, longitude, place_name, result_time @@ -1826,8 +1897,14 @@ zoomWatcher = { } catch(err) { console.error("Hash pid query failed:", err); } + } else if (state.h3) { + viewer._globeState.selectedPid = null; + viewer._globeState.selectedH3 = state.h3; + const meta = await fetchClusterByH3(state.h3); + if (meta) await hydrateClusterUI(meta); } else { viewer._globeState.selectedPid = null; + viewer._globeState.selectedH3 = null; updateClusterCard(null); } }); @@ -2159,9 +2236,11 @@ zoomWatcher = { refreshFacetCounts(); // --- Deep-link: restore selection from initial hash --- + // Sample mode wins if both &pid= and &h3= are present (see EXPLORER_CLUSTER_URL_PROPOSAL §4). const ih = viewer._initialHash; if (ih.pid) { viewer._globeState.selectedPid = ih.pid; + viewer._globeState.selectedH3 = null; try { const sample = await db.query(` SELECT pid, label, source, latitude, longitude, place_name, result_time @@ -2187,6 +2266,10 @@ zoomWatcher = { } catch(err) { console.error("Deep-link pid query failed:", err); } + } else if (ih.h3) { + viewer._globeState.selectedH3 = ih.h3; + const meta = await fetchClusterByH3(ih.h3); + if (meta) await hydrateClusterUI(meta); } // Enable hash writing now that everything is initialized From a6c328f77b27226208231e5f1bedc96893630566 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 09:50:30 -0700 Subject: [PATCH 2/7] explorer: address Codex review of #186 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes from Codex review: 1. Race-safe hash hydration (BLOCKER) Both pid and h3 hashchange branches now use a monotonic `viewer._selGen` token, bumped per hashchange and rechecked after every await. Fast back/forward across pid/h3/empty no longer lets stale fetch results repaint the side panel. 2. Strict h3 validation Replaced `replace(/[^0-9a-fA-F]/g, '')` with `/^[0-9a-f]{15}$/i.test()` over a lowercased input. Reject malformed input rather than silently strip — `h3=xxx843f...` no longer becomes a different lookup key. 3. Canonical lowercase normalization After successful lookup, runtime `selectedH3` is set from the parquet row's `h3_cell.toString(16)` (always lowercase), not the raw URL token. Subsequent `buildHash` writes always emit canonical form regardless of what the user typed. Boot deep-link applies the same normalization. 4. Resolution routing instead of UNION ALL Canonical H3 cells encode resolution in the 2nd hex char (after the leading-zero strip). `RES_TO_H3_URL[parseInt(lower[1], 16)]` picks the right parquet directly — one fetch instead of three on every &h3= load. 5. Mutual-exclusion in buildHash Changed independent `if`s for `selectedPid` / `selectedH3` to `else if`, making the runtime invariant load-bearing in one place. Also: unknown / malformed h3 now actively clears the cluster card and nearby-samples list, matching the empty-hash and missing-pid paths (previously left stale content). Verified locally: - Uppercase #h3=843F6D3FFFFFFFF — hydrates, then runtime canonicalizes. - Unknown well-formed cell #h3=843ffffffffffff — side panel clears, no errors. - Non-hex #h3=zzz_NOT_HEX_zzz — silent reject, no JS errors. - Known #h3=843f6d3ffffffff — round-trips identically. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 80 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index 5d87465..38b84ef 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -641,8 +641,11 @@ function buildHash(v) { if (Math.abs(pitch + 90) > 1) params.set('pitch', pitch.toFixed(1)); const gs = v._globeState; if (gs.mode === 'point') params.set('mode', 'point'); + // pid and h3 are mutually exclusive at runtime; emit at most one. Sample + // selection (pid) wins if somehow both are set — matches the hydration + // priority in the hashchange handler. if (gs.selectedPid) params.set('pid', gs.selectedPid); - if (gs.selectedH3) params.set('h3', gs.selectedH3); + else if (gs.selectedH3) params.set('h3', gs.selectedH3); return '#' + params.toString(); } @@ -1785,28 +1788,29 @@ zoomWatcher = { }); viewer.camera.percentageChanged = 0.1; - // --- Helper: hydrate cluster row from H3 cell index across all 3 resolution parquets --- - // Used by both the boot deep-link path and the back/forward hashchange handler. - // The cell only exists in one of res4/res6/res8; UNION ALL + LIMIT 1 picks it. + // --- Helper: hydrate cluster row from H3 cell index --- + // Canonical H3 cells encode resolution in the 2nd hex char (the leading-zero- + // stripped form is `8<...>` for cell-mode indices). We support res 4/6/8 + // — the parquets we have. Reject malformed input rather than silently strip: + // a stray prefix (`xxx843f...`) would otherwise change which key we look up. + // h3_cell is UBIGINT in the parquet; DuckDB-WASM rejects `0x...` literals, so + // hex → decimal happens in JS via BigInt, then CAST to UBIGINT in SQL. + const RES_TO_H3_URL = { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }; async function fetchClusterByH3(cellHex) { - // h3_cell is stored as UBIGINT in the parquet; we receive a hex string - // (canonical H3 form). DuckDB-WASM doesn't accept `0x...` literals, so - // convert hex → decimal in JS via BigInt, then CAST to UBIGINT in SQL. - const safe = String(cellHex).replace(/[^0-9a-fA-F]/g, ''); - if (!safe) return null; + if (typeof cellHex !== 'string') return null; + const lower = cellHex.toLowerCase(); + if (!/^[0-9a-f]{15}$/.test(lower)) return null; + const res = parseInt(lower[1], 16); + const url = RES_TO_H3_URL[res]; + if (!url) return null; let decimal; - try { decimal = BigInt('0x' + safe).toString(); } + try { decimal = BigInt('0x' + lower).toString(); } catch { return null; } try { const result = await db.query(` - SELECT 4 AS resolution, h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count - FROM read_parquet('${h3_res4_url}') WHERE h3_cell = CAST('${decimal}' AS UBIGINT) - UNION ALL - SELECT 6 AS resolution, h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count - FROM read_parquet('${h3_res6_url}') WHERE h3_cell = CAST('${decimal}' AS UBIGINT) - UNION ALL - SELECT 8 AS resolution, h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count - FROM read_parquet('${h3_res8_url}') WHERE h3_cell = CAST('${decimal}' AS UBIGINT) + SELECT h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count + FROM read_parquet('${url}') + WHERE h3_cell = CAST('${decimal}' AS UBIGINT) LIMIT 1 `); if (!result || result.length === 0) return null; @@ -1817,7 +1821,7 @@ zoomWatcher = { source: r.dominant_source, lat: r.center_lat, lng: r.center_lng, - resolution: r.resolution, + resolution: res, }; } catch(err) { console.error("fetchClusterByH3 query failed:", err); @@ -1875,7 +1879,14 @@ zoomWatcher = { else if (s.mode !== 'point' && mode === 'point') exitPointMode(false); }, 2000); - // Handle pid selection (sample mode wins if both present — see EXPLORER_CLUSTER_URL_PROPOSAL §4) + // Handle pid / h3 selection (sample mode wins if both present — see + // EXPLORER_CLUSTER_URL_PROPOSAL §4). Both branches do an `await` against + // a remote parquet, so a fast back/forward could race: an older fetch + // resolves AFTER a newer hash has applied, and would otherwise repaint + // the side panel with stale data. `selGen` is bumped per hashchange and + // checked after each await to drop stale results. + viewer._selGen = (viewer._selGen || 0) + 1; + const selGen = viewer._selGen; if (state.pid) { viewer._globeState.selectedPid = state.pid; viewer._globeState.selectedH3 = null; @@ -1886,6 +1897,7 @@ zoomWatcher = { WHERE pid = '${state.pid.replace(/'/g, "''")}' LIMIT 1 `); + if (selGen !== viewer._selGen) return; if (sample && sample.length > 0) { const s = sample[0]; updateSampleCard({ @@ -1899,13 +1911,26 @@ zoomWatcher = { } } else if (state.h3) { viewer._globeState.selectedPid = null; - viewer._globeState.selectedH3 = state.h3; + viewer._globeState.selectedH3 = state.h3.toLowerCase(); const meta = await fetchClusterByH3(state.h3); - if (meta) await hydrateClusterUI(meta); + if (selGen !== viewer._selGen) return; + if (meta) { + viewer._globeState.selectedH3 = meta.h3_cell; // canonical lowercase + await hydrateClusterUI(meta); + if (selGen !== viewer._selGen) return; + } else { + // Unknown / malformed h3 — clear the side panel rather than + // leaving prior content stranded. + updateClusterCard(null); + const sampEl = document.getElementById('samplesSection'); + if (sampEl) sampEl.innerHTML = ''; + } } else { viewer._globeState.selectedPid = null; viewer._globeState.selectedH3 = null; updateClusterCard(null); + const sampEl = document.getElementById('samplesSection'); + if (sampEl) sampEl.innerHTML = ''; } }); @@ -2267,9 +2292,16 @@ zoomWatcher = { console.error("Deep-link pid query failed:", err); } } else if (ih.h3) { - viewer._globeState.selectedH3 = ih.h3; + viewer._globeState.selectedH3 = ih.h3.toLowerCase(); const meta = await fetchClusterByH3(ih.h3); - if (meta) await hydrateClusterUI(meta); + if (meta) { + viewer._globeState.selectedH3 = meta.h3_cell; // canonical lowercase + await hydrateClusterUI(meta); + } else { + // Unknown / malformed h3 — drop it from runtime state so subsequent + // buildHash() calls don't keep emitting it. + viewer._globeState.selectedH3 = null; + } } // Enable hash writing now that everything is initialized From 1e8ef15ffa4beec7e3a009243c04e5d790b4ee4e Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 09:59:46 -0700 Subject: [PATCH 3/7] explorer: thread freshness check into hydrateClusterUI; bump _selGen earlier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's second review found the previous race fix was incomplete: hydrateClusterUI has its own internal `await db.query(...)` for the nearby- samples list, then calls updateSamples(samples). The hashchange-handler- side selGen check happened only AFTER hydrateClusterUI returned, so a stale fetch INSIDE hydrateClusterUI could still repaint the side panel with samples for an older h3 selection. Fix: hydrateClusterUI now accepts an optional `isStale` predicate and checks it after its inner await, before updateSamples (and before the catch-path's "Query failed" message). The hashchange caller passes `() => selGen !== viewer._selGen`. The cluster-click and boot-deep-link callers leave it undefined — clicks are user-serialized and there's only one boot, so no race possible there. Also (Codex non-blocking nits): - Bump `_selGen` at the very top of the hashchange handler, before the lat/lng early return — so even hashchanges that lack lat/lng invalidate any in-flight stale work. - Reject non-cell H3 modes (`lower[0] !== '8'`) in fetchClusterByH3 — defensive guard against edges/vertices/etc. ever ending up in `&h3=`. Verified locally: known-good `#h3=843f6d3ffffffff` round-trips identically (151,334 OpenContext samples, 30 nearby rendered). Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index 38b84ef..ff4415b 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -1800,6 +1800,10 @@ zoomWatcher = { if (typeof cellHex !== 'string') return null; const lower = cellHex.toLowerCase(); if (!/^[0-9a-f]{15}$/.test(lower)) return null; + // First nibble of the leading-zero-stripped 15-char form is the H3 mode + // (cells are mode 1 → '8' here). Reject non-cell modes (edges, vertices, + // etc.) so we don't issue spurious lookups for them. + if (lower[0] !== '8') return null; const res = parseInt(lower[1], 16); const url = RES_TO_H3_URL[res]; if (!url) return null; @@ -1831,7 +1835,11 @@ zoomWatcher = { // --- Helper: populate side panel + nearby-samples list from a cluster meta object --- // Mirrors the cluster-click handler at the LEFT_CLICK input action above. - async function hydrateClusterUI(meta) { + // The optional `isStale` predicate is consulted *after* the inner await so + // that a hashchange superseded by a newer one doesn't paint stale samples + // into the side panel. The cluster-click caller passes a no-op predicate + // because click selection is its own latest event. + async function hydrateClusterUI(meta, isStale) { if (!meta) return; updateClusterCard(meta); const sampEl = document.getElementById('samplesSection'); @@ -1847,8 +1855,10 @@ zoomWatcher = { ${facetFilterSQL()} LIMIT 30 `); + if (isStale && isStale()) return; updateSamples(samples); } catch(err) { + if (isStale && isStale()) return; console.error("Cluster hydration nearby query failed:", err); if (sampEl) sampEl.innerHTML = '
Query failed — try again.
'; } @@ -1856,6 +1866,9 @@ zoomWatcher = { // --- Handle browser back/forward --- window.addEventListener('hashchange', async () => { + // Bump the selection generation BEFORE any early-return so even + // hashchanges that lack lat/lng invalidate stale async work. + viewer._selGen = (viewer._selGen || 0) + 1; const state = readHash(); if (state.lat == null || state.lng == null) return; @@ -1883,9 +1896,8 @@ zoomWatcher = { // EXPLORER_CLUSTER_URL_PROPOSAL §4). Both branches do an `await` against // a remote parquet, so a fast back/forward could race: an older fetch // resolves AFTER a newer hash has applied, and would otherwise repaint - // the side panel with stale data. `selGen` is bumped per hashchange and - // checked after each await to drop stale results. - viewer._selGen = (viewer._selGen || 0) + 1; + // the side panel with stale data. `_selGen` is bumped at the very top + // of this handler; we capture it here and check after each await. const selGen = viewer._selGen; if (state.pid) { viewer._globeState.selectedPid = state.pid; @@ -1916,8 +1928,7 @@ zoomWatcher = { if (selGen !== viewer._selGen) return; if (meta) { viewer._globeState.selectedH3 = meta.h3_cell; // canonical lowercase - await hydrateClusterUI(meta); - if (selGen !== viewer._selGen) return; + await hydrateClusterUI(meta, () => selGen !== viewer._selGen); } else { // Unknown / malformed h3 — clear the side panel rather than // leaving prior content stranded. From 90d3fdfca201fd98ca28929291c56e481fb58008 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 10:13:19 -0700 Subject: [PATCH 4/7] =?UTF-8?q?explorer:=20address=20Codex=20review=20v3?= =?UTF-8?q?=20=E2=80=94=20boot=20race=20+=20source-filter=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2 findings from Codex's third pass on PR #186: 1. Boot deep-link could still race with a later hashchange. The hashchange listener registers earlier in the same OJS cell, so a slow initial &h3= or &pid= lookup can be superseded by browser back/forward (or a manual hash edit) during the await — the boot path would then finish later and repaint stale data. Apply the same _selGen guard to the boot path: bump the token at boot start, capture bootSelGen, define isBootStale = () => bootSelGen !== viewer._selGen, and check it after every await (pid lookup, wide-parquet description fetch, h3 lookup, and inside hydrateClusterUI via the existing isStale-predicate parameter). 2. fetchClusterByH3 bypassed the active source filter. The cluster lookup did `WHERE h3_cell = ?` without sourceFilterSQL — so an &h3= URL whose dominant_source is currently unchecked in ?sources= would still hydrate a cluster card for a dot the user can't see on the globe. Worse, hydrateClusterUI's nearby-samples query DOES apply source filter, producing a mismatched panel: full unfiltered cluster card with a filtered-down samples list. Add sourceFilterSQL('dominant_source') to the lookup; an excluded source now returns null and the side panel stays empty (matching what the globe shows). Verified locally: - ?sources=SESAR,GEOME,SMITHSONIAN#h3=843f6d3ffffffff (the cluster's dominant_source OPENCONTEXT is excluded) → side panel stays empty. - ?sources= default (all checked) #h3=843f6d3ffffffff → hydrates as before with 151,334 OpenContext samples and 30 nearby rows. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index ff4415b..992f8f1 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -1811,10 +1811,17 @@ zoomWatcher = { try { decimal = BigInt('0x' + lower).toString(); } catch { return null; } try { + // Honor the active ?sources= filter — same predicate phase1/loadRes + // apply when rendering clusters (`:976`, `:1319`). A cluster whose + // dominant_source is currently unchecked wouldn't be visible on the + // globe, so hydrating its side panel would mismatch the user's view + // (and would inconsistently combine unfiltered cluster card with + // source-filtered nearby-samples in hydrateClusterUI). const result = await db.query(` SELECT h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count FROM read_parquet('${url}') WHERE h3_cell = CAST('${decimal}' AS UBIGINT) + ${sourceFilterSQL('dominant_source')} LIMIT 1 `); if (!result || result.length === 0) return null; @@ -2273,6 +2280,13 @@ zoomWatcher = { // --- Deep-link: restore selection from initial hash --- // Sample mode wins if both &pid= and &h3= are present (see EXPLORER_CLUSTER_URL_PROPOSAL §4). + // The boot path runs once, but the hashchange listener is already registered + // by this point — back/forward or a manual hash edit during the boot await + // could supersede this lookup. Use the same `_selGen` token the hashchange + // handler uses; bumping it here also invalidates any in-flight lookups. + viewer._selGen = (viewer._selGen || 0) + 1; + const bootSelGen = viewer._selGen; + const isBootStale = () => bootSelGen !== viewer._selGen; const ih = viewer._initialHash; if (ih.pid) { viewer._globeState.selectedPid = ih.pid; @@ -2284,6 +2298,7 @@ zoomWatcher = { WHERE pid = '${ih.pid.replace(/'/g, "''")}' LIMIT 1 `); + if (isBootStale()) return "active"; if (sample && sample.length > 0) { const s = sample[0]; updateSampleCard({ @@ -2296,6 +2311,7 @@ zoomWatcher = { WHERE pid = '${ih.pid.replace(/'/g, "''")}' LIMIT 1 `); + if (isBootStale()) return "active"; if (detail && detail.length > 0) updateSampleDetail(detail[0]); else updateSampleDetail({ description: '' }); } @@ -2305,12 +2321,13 @@ zoomWatcher = { } else if (ih.h3) { viewer._globeState.selectedH3 = ih.h3.toLowerCase(); const meta = await fetchClusterByH3(ih.h3); + if (isBootStale()) return "active"; if (meta) { viewer._globeState.selectedH3 = meta.h3_cell; // canonical lowercase - await hydrateClusterUI(meta); + await hydrateClusterUI(meta, isBootStale); } else { - // Unknown / malformed h3 — drop it from runtime state so subsequent - // buildHash() calls don't keep emitting it. + // Unknown / malformed h3, OR filtered out by ?sources=. Drop it from + // runtime state so subsequent buildHash() calls don't keep emitting it. viewer._globeState.selectedH3 = null; } } From ebd7978f392c83cb81439e1295661554949c991e Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 10:20:47 -0700 Subject: [PATCH 5/7] explorer: source-filter invalidates selection; boot finalize in try/finally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Codex's fourth review: 1. Source-filter changes don't invalidate selection state. When the user unchecked the source for an already-hydrated cluster (or sample), the globe correctly hid the dot but the side panel and `&h3=` / `&pid=` URL stayed stale. Source filter changes also raced against in-flight selection lookups since they didn't bump `_selGen`. Fix: in the source-filter change handler (`:1690`), bump `_selGen` immediately, then after the existing globe-data reload, re-validate the current selection under the new filter: - Cluster (selectedH3): re-run fetchClusterByH3 (already honors sourceFilterSQL after v3); if returns null, clear selectedH3, cluster card, samples list, and rewrite the URL via replaceState. - Sample (selectedPid): probe lite_url with the same source filter; if no match, clear selectedPid + side panel + URL. Both branches re-check `_selGen` after the await to bail if a newer filter change has fired. 2. Boot's stale-abort early-returns skipped `_suppressHashWrite = false`. A no-lat/lng hashchange during boot's awaits could leave hash writes suppressed forever (the lat/lng path clears it via _suppressTimer; a stale-aborted boot leaves it set with no later cleanup). Fix: wrap the boot deep-link block in try/finally; move the `_suppressHashWrite = false` assignment into the finally so it runs on every path, including stale-abort early returns. Verified locally: - Load #h3=843f6d3ffffffff (OpenContext cluster); side panel hydrates. - Uncheck OPENCONTEXT in the source filter → `&h3=` drops from URL, cluster card returns to empty state, samples list clears, ?sources= written with the remaining 3 sources. Globe also re-renders without OpenContext clusters. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 123 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 84 insertions(+), 39 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index 992f8f1..d36385e 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -1689,6 +1689,12 @@ zoomWatcher = { const resUrls = { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }; document.getElementById('sourceFilter').addEventListener('change', async () => { busyAcquire(); + // Source filter affects which clusters / samples are visible. Invalidate + // any in-flight selection lookup AND re-validate the current selection + // (cluster or sample) under the new filter — if it's filtered out, drop + // it from runtime state and the URL so the side panel matches the globe. + viewer._selGen = (viewer._selGen || 0) + 1; + const filterSelGen = viewer._selGen; try { updateSourceLegendState(); writeQueryState(); @@ -1700,6 +1706,39 @@ zoomWatcher = { await loadViewportSamples(); } refreshFacetCounts(); + + // Re-validate selection (only if no newer filter change has fired). + if (filterSelGen === viewer._selGen) { + const sel = viewer._globeState; + if (sel.selectedH3) { + const meta = await fetchClusterByH3(sel.selectedH3); + if (filterSelGen !== viewer._selGen) return; + if (!meta) { + sel.selectedH3 = null; + updateClusterCard(null); + const sampEl = document.getElementById('samplesSection'); + if (sampEl) sampEl.innerHTML = ''; + history.replaceState(null, '', buildHash(viewer)); + } + } else if (sel.selectedPid) { + const safe = sel.selectedPid.replace(/'/g, "''"); + const stillVisible = await db.query(` + SELECT 1 FROM read_parquet('${lite_url}') + WHERE pid = '${safe}' + ${sourceFilterSQL('source')} + LIMIT 1 + `); + if (filterSelGen !== viewer._selGen) return; + if (!stillVisible || stillVisible.length === 0) { + sel.selectedPid = null; + updateClusterCard(null); + const sampEl = document.getElementById('samplesSection'); + if (sampEl) sampEl.innerHTML = ''; + history.replaceState(null, '', buildHash(viewer)); + } + } + } + await new Promise(r => setTimeout(r, 300)); } finally { busyRelease(); @@ -2284,57 +2323,63 @@ zoomWatcher = { // by this point — back/forward or a manual hash edit during the boot await // could supersede this lookup. Use the same `_selGen` token the hashchange // handler uses; bumping it here also invalidates any in-flight lookups. + // Wrap in try/finally so `_suppressHashWrite = false` always runs even if + // a stale early-return aborts the deep-link work — otherwise a no-lat/lng + // hashchange during boot could leave hash writes suppressed forever. viewer._selGen = (viewer._selGen || 0) + 1; const bootSelGen = viewer._selGen; const isBootStale = () => bootSelGen !== viewer._selGen; const ih = viewer._initialHash; - if (ih.pid) { - viewer._globeState.selectedPid = ih.pid; - viewer._globeState.selectedH3 = null; - try { - const sample = await db.query(` - SELECT pid, label, source, latitude, longitude, place_name, result_time - FROM read_parquet('${lite_url}') - WHERE pid = '${ih.pid.replace(/'/g, "''")}' - LIMIT 1 - `); - if (isBootStale()) return "active"; - if (sample && sample.length > 0) { - const s = sample[0]; - updateSampleCard({ - pid: s.pid, label: s.label, source: s.source, - lat: s.latitude, lng: s.longitude, - place_name: s.place_name, result_time: s.result_time - }); - const detail = await db.query(` - SELECT description FROM read_parquet('${wide_url}') + try { + if (ih.pid) { + viewer._globeState.selectedPid = ih.pid; + viewer._globeState.selectedH3 = null; + try { + const sample = await db.query(` + SELECT pid, label, source, latitude, longitude, place_name, result_time + FROM read_parquet('${lite_url}') WHERE pid = '${ih.pid.replace(/'/g, "''")}' LIMIT 1 `); if (isBootStale()) return "active"; - if (detail && detail.length > 0) updateSampleDetail(detail[0]); - else updateSampleDetail({ description: '' }); + if (sample && sample.length > 0) { + const s = sample[0]; + updateSampleCard({ + pid: s.pid, label: s.label, source: s.source, + lat: s.latitude, lng: s.longitude, + place_name: s.place_name, result_time: s.result_time + }); + const detail = await db.query(` + SELECT description FROM read_parquet('${wide_url}') + WHERE pid = '${ih.pid.replace(/'/g, "''")}' + LIMIT 1 + `); + if (isBootStale()) return "active"; + if (detail && detail.length > 0) updateSampleDetail(detail[0]); + else updateSampleDetail({ description: '' }); + } + } catch(err) { + console.error("Deep-link pid query failed:", err); + } + } else if (ih.h3) { + viewer._globeState.selectedH3 = ih.h3.toLowerCase(); + const meta = await fetchClusterByH3(ih.h3); + if (isBootStale()) return "active"; + if (meta) { + viewer._globeState.selectedH3 = meta.h3_cell; // canonical lowercase + await hydrateClusterUI(meta, isBootStale); + } else { + // Unknown / malformed h3, OR filtered out by ?sources=. Drop it + // from runtime state so buildHash() doesn't keep emitting it. + viewer._globeState.selectedH3 = null; } - } catch(err) { - console.error("Deep-link pid query failed:", err); - } - } else if (ih.h3) { - viewer._globeState.selectedH3 = ih.h3.toLowerCase(); - const meta = await fetchClusterByH3(ih.h3); - if (isBootStale()) return "active"; - if (meta) { - viewer._globeState.selectedH3 = meta.h3_cell; // canonical lowercase - await hydrateClusterUI(meta, isBootStale); - } else { - // Unknown / malformed h3, OR filtered out by ?sources=. Drop it from - // runtime state so subsequent buildHash() calls don't keep emitting it. - viewer._globeState.selectedH3 = null; } + } finally { + // Enable hash writing now that everything is initialized — runs even on + // stale-abort early returns above. + viewer._suppressHashWrite = false; } - // Enable hash writing now that everything is initialized - viewer._suppressHashWrite = false; - return "active"; } ``` From 0ee44b7cf10e020a24a62125ed4cf783de5d997a Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 10:49:20 -0700 Subject: [PATCH 6/7] explorer: fix UBIGINT precision-loss in h3_cell + rehydrate cluster on filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from Codex's fifth review: 1. (P2 NEW) Selected cluster surviving the filter wasn't being rehydrated. When the user toggled a non-cluster source (e.g. unchecked SESAR while the selected cluster's dominant_source = OPENCONTEXT), the cluster stayed in URL but the nearby-samples list could now show stale rows from unchecked sources or miss newly-checked ones (hydrateClusterUI's nearby query uses sourceFilterSQL('source')). Fix: in the source-filter handler's revalidate branch, when meta is truthy (cluster still valid), call hydrateClusterUI(meta, isStale) to refresh the side panel under the new filter — not just leave it. 2. (UBIGINT precision regression — surfaced by testing #1) DuckDB-WASM returns h3_cell (UBIGINT > 2^53) as a JS Number, which loses precision on .toString(16). Boot worked because the SQL WHERE matched at the parquet level, but `selectedH3 = meta.h3_cell` (lossy roundtrip) stored a corrupted hex; subsequent revalidations against the corrupted key would never match and the panel would clear. The bug was latent in PR-as-of-ebd7978; the rehydrate branch above made it visible. Fix: SQL SELECT now CASTs h3_cell to VARCHAR (decimal string), and JS converts to hex via BigInt(decString).toString(16) — no precision loss. Applied at the two cluster-render sites (phase1, loadRes). fetchClusterByH3's return now uses the validated input `lower` as the canonical hex so the helper is also lossless. `to_hex()` in DuckDB-WASM doesn't exist (tried first, errored "Catalog Error: Scalar Function with name to_hex does not exist!" — the VARCHAR cast + JS BigInt is portable across versions). Verified locally: - Boot at #h3=843f6d3ffffffff hydrates correctly. - Uncheck SESAR (OPENCONTEXT survives): cluster card unchanged, samples re-rendered with only OpenContext rows, &h3= preserved. - Uncheck OPENCONTEXT (cluster's own source): card + samples cleared, &h3= dropped from URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index d36385e..686d23a 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -977,7 +977,8 @@ phase1 = { applyQueryToSourceFilter(); const data = await db.query(` - SELECT h3_cell, sample_count, center_lat, center_lng, + SELECT CAST(h3_cell AS VARCHAR) AS h3_cell_dec, + sample_count, center_lat, center_lng, dominant_source, source_count FROM read_parquet('${h3_res4_url}') WHERE 1=1${sourceFilterSQL('dominant_source')} @@ -990,9 +991,12 @@ phase1 = { const count = row.sample_count; totalSamples += count; const size = Math.min(3 + Math.log10(count) * 4, 20); + // h3_cell is UBIGINT (>2^53 for typical H3 indices). DuckDB-WASM + // returns UBIGINT as a JS Number, losing precision in toString(16). + // SELECT casts to VARCHAR (decimal); convert to hex via BigInt here. + const h3Hex = BigInt(row.h3_cell_dec).toString(16); viewer.h3Points.add({ - // h3_cell is UBIGINT in the parquet; carry as hex string (canonical H3 form). - id: { h3_cell: row.h3_cell.toString(16), count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: 4 }, + id: { h3_cell: h3Hex, count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: 4 }, position: Cesium.Cartesian3.fromDegrees(row.center_lng, row.center_lat, 0), pixelSize: size, color: Cesium.Color.fromCssColorString(SOURCE_COLORS[row.dominant_source] || '#666').withAlpha(0.8), @@ -1320,7 +1324,8 @@ zoomWatcher = { try { performance.mark(`r${res}-s`); const data = await db.query(` - SELECT h3_cell, sample_count, center_lat, center_lng, + SELECT CAST(h3_cell AS VARCHAR) AS h3_cell_dec, + sample_count, center_lat, center_lng, dominant_source, source_count FROM read_parquet('${url}') WHERE 1=1${sourceFilterSQL('dominant_source')} @@ -1334,8 +1339,9 @@ zoomWatcher = { for (const row of data) { total += row.sample_count; const size = Math.min(3 + Math.log10(row.sample_count) * 3.5, 18); + const h3Hex = BigInt(row.h3_cell_dec).toString(16); viewer.h3Points.add({ - id: { h3_cell: row.h3_cell.toString(16), count: row.sample_count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: res }, + id: { h3_cell: h3Hex, count: row.sample_count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: res }, position: Cesium.Cartesian3.fromDegrees(row.center_lng, row.center_lat, 0), pixelSize: size, color: Cesium.Color.fromCssColorString(SOURCE_COLORS[row.dominant_source] || '#666').withAlpha(0.85), @@ -1719,6 +1725,13 @@ zoomWatcher = { const sampEl = document.getElementById('samplesSection'); if (sampEl) sampEl.innerHTML = ''; history.replaceState(null, '', buildHash(viewer)); + } else { + // Cluster's dominant_source still checked, but the + // nearby-samples list inside hydrateClusterUI is + // source-filtered too — re-run it under the new filter + // so the panel doesn't show stale rows from unchecked + // sources (or miss newly-checked ones). + await hydrateClusterUI(meta, () => filterSelGen !== viewer._selGen); } } else if (sel.selectedPid) { const safe = sel.selectedPid.replace(/'/g, "''"); @@ -1857,7 +1870,7 @@ zoomWatcher = { // (and would inconsistently combine unfiltered cluster card with // source-filtered nearby-samples in hydrateClusterUI). const result = await db.query(` - SELECT h3_cell, sample_count, center_lat, center_lng, dominant_source, source_count + SELECT sample_count, center_lat, center_lng, dominant_source, source_count FROM read_parquet('${url}') WHERE h3_cell = CAST('${decimal}' AS UBIGINT) ${sourceFilterSQL('dominant_source')} @@ -1866,7 +1879,10 @@ zoomWatcher = { if (!result || result.length === 0) return null; const r = result[0]; return { - h3_cell: r.h3_cell.toString(16), + // The validated input `lower` is the canonical hex; round-tripping + // r.h3_cell would lose precision (DuckDB-WASM returns UBIGINT as + // JS Number for values > 2^53). + h3_cell: lower, count: r.sample_count, source: r.dominant_source, lat: r.center_lat, From efd116df1873b20b2fcbce589c7d449421be7d04 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 11:17:47 -0700 Subject: [PATCH 7/7] docs: sync EXPLORER_STATE.md h3 row to current implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's sixth review only finding (P3, non-runtime): the EXPLORER_STATE.md description still reflected the original v1 implementation: - "regex `[^0-9a-fA-F]` strip" → now strict `/^[0-9a-f]{15}$/i` reject-not-strip - "UNION ALL across all 3 parquets" → now resolution-routed via RES_TO_H3_URL - Missing: cell-mode guard (`lower[0] === '8'`) - Missing: source filter applied (sourceFilterSQL('dominant_source')) - Missing: UBIGINT precision-loss workaround (CAST AS VARCHAR + BigInt) - Missing: source-filter change re-validation - Missing: _selGen race guard Updated the h3 row to describe current behavior so future URL-state work finds accurate docs. --- EXPLORER_STATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXPLORER_STATE.md b/EXPLORER_STATE.md index 58116a8..923fd9c 100644 --- a/EXPLORER_STATE.md +++ b/EXPLORER_STATE.md @@ -54,7 +54,7 @@ The hash is the camera/deep-link channel. Always written together by | `pitch` | `viewer.camera.pitch` | `-90` | degrees, 1-decimal | same | same; only written if `\|pitch + 90\| > 1` (`:608`) | clamped `[-90, 0]` (`:591`) | | | `mode` | `viewer._globeState.mode` | omitted (= `cluster`) | `point` only | `readHash()` (`:592`); applied after camera flight in `hashchange` handler (`:1727-1729`); also restored from `_initialHash` after zoomWatcher init | `buildHash` only writes if `'point'` (`:610`); push triggers as above | exact-match `'point'` | absence ⇒ cluster | | `pid` | `viewer._globeState.selectedPid` | omitted | sample pid string (URL-encoded) | `readHash()` (`:593`); applied at end of `zoomWatcher` (`:1873-1901`) and on `hashchange` (`:1733-1756`) | sample-click sets it (`:860`); cluster-click clears it (`:887`); written in `buildHash` if non-null (`:611`) | none beyond `null` check | drives a `lite_url` lookup + lazy `wide_url` description fetch | -| `h3` | `viewer._globeState.selectedH3` | omitted | H3 cell index hex string (e.g. `841a067ffffffff`) | `readHash()` parses; boot deep-link calls `fetchClusterByH3` then `hydrateClusterUI`; same path on `hashchange` | cluster-click sets `selectedH3 = meta.h3_cell` and clears `selectedPid` (mutual exclusion); sample-click clears `selectedH3`; written in `buildHash` if non-null | regex `[^0-9a-fA-F]` strip; null check | drives a `WHERE h3_cell = ?` lookup against `h3_res4_url` / `h3_res6_url` / `h3_res8_url` (UNION ALL, LIMIT 1). `&pid=` wins if both present. Per `EXPLORER_CLUSTER_URL_PROPOSAL.md` | +| `h3` | `viewer._globeState.selectedH3` | omitted | canonical 15-char lowercase hex (e.g. `843f6d3ffffffff`) | `readHash()` parses; boot deep-link calls `fetchClusterByH3` then `hydrateClusterUI` under a `_selGen` race guard; same path on `hashchange` | cluster-click sets `selectedH3 = meta.h3_cell` and clears `selectedPid` (mutual exclusion); sample-click clears `selectedH3`; source-filter change re-validates and may clear or rehydrate; written in `buildHash` if non-null | strict `/^[0-9a-f]{15}$/i`; cell-mode (`lower[0] === '8'`); resolution nibble in `RES_TO_H3_URL` map (4/6/8) | drives a single `WHERE h3_cell = CAST('' AS UBIGINT) AND ` lookup against the resolution-routed parquet. h3_cell column is UBIGINT so SELECTs cast to VARCHAR and JS converts via `BigInt(dec).toString(16)` to avoid Number precision loss. `&pid=` wins if both present. Per `EXPLORER_CLUSTER_URL_PROPOSAL.md` | ### Hash write-vs-read coordination