From ffc744dddf38d50622d11c7b5c116d0b49e20dc9 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 08:34:57 -0700 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20proposal=20=E2=80=94=20URL-persist?= =?UTF-8?q?=20selected=20cluster=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to EXPLORER_STATE.md. Audits what selection state the explorer URL captures today (sample selection via &pid is already wired; cluster selection is in-memory only) and proposes adding &cluster= as a single packed token (source:res:lat,lng) with no backwards-compat tax for existing &pid URLs. Doc-only — no code changes. Open questions called out for review: - Whether the parquet schema already carries an H3 cell index (would let us prefer &h3= over the lat/lng-tuple encoding) - Cross-resolution UX on load (force camera vs populate side-panel only) Co-Authored-By: Claude Opus 4.7 (1M context) --- EXPLORER_CLUSTER_URL_PROPOSAL.md | 193 +++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 EXPLORER_CLUSTER_URL_PROPOSAL.md diff --git a/EXPLORER_CLUSTER_URL_PROPOSAL.md b/EXPLORER_CLUSTER_URL_PROPOSAL.md new file mode 100644 index 0000000..c855eef --- /dev/null +++ b/EXPLORER_CLUSTER_URL_PROPOSAL.md @@ -0,0 +1,193 @@ +# Explorer Selection URL State — Audit + Cluster Proposal + +Companion to [`EXPLORER_STATE.md`](./EXPLORER_STATE.md). Audit of what selection state +the Explorer URL captures today, and a proposal for adding cluster-selection +state so a URL alone can replay "I clicked this dot and got these samples." + +All file:line references are against `explorer.qmd` at commit `e0043c8` +(post-#180 polish PR). + +--- + +## 1. Audit: what's URL-persisted vs in-memory only + +### Already URL-persisted + +These already round-trip through the URL today; no work needed. + +| State | URL token | Notes | +|---|---|---| +| Camera position | `#lat`, `#lng`, `#alt`, `#heading`, `#pitch` | Hash; debounced 600 ms; `EXPLORER_STATE.md §2`. | +| Mode (cluster vs point) | `#mode=point` (absent ⇒ cluster) | Hash. Pushed on `enter/exitPointMode`. | +| **Selected sample** | **`#pid=`** | Hash. `selectedPid` written by sample-click (`:892`), cleared by cluster-click (`:919`). Round-trips through `readHash` → `_globeState.selectedPid` → `updateSampleCard()` + lazy `wide_url` description fetch (`:1813`, `:2167`). | +| Search query | `?search=` | Query-string. `EXPLORER_STATE.md §1`. | +| Search scope | `?search_scope=area\|world` | Query-string. From PR #179. | +| View mode (globe/table) | `?view=table` (absent ⇒ globe) | Query-string. | +| Source filter (4 source toggles) | `?sources=CSV` | Query-string. | +| Material / Sampled Feature / Specimen Type facet filters | `?material=`, `?context=`, `?object_type=` | Query-string. CSV of full URIs. | + +**Sample selection is solved.** A URL like +`#v=1&lat=33.27&lng=-86.24&alt=311435&pid=ark%3A%2F65665%2F...&mode=point` drops a +collaborator at the exact dot you clicked, with the side-panel sample card +populated. + +### NOT URL-persisted (in-memory only) + +| State | Where it lives | Lost on reload? | +|---|---|---| +| **Selected cluster** | `updateClusterCard()` writes the cluster card DOM only; `selectedPid` is *cleared* on cluster click (`:919`) | ✓ | +| Nearby-samples panel (post-cluster-click result) | `samplesSection` DOM, populated from a `delta`-window query (`:925-960`) | ✓ — derivable from cluster identity if we had it | +| Sample card detail (description) | `samplesSection` / sample-card DOM | ✗ — refetched on `pid` reload via `wide_url` | +| Table page index | `let page = 0` closure in `tableView` (`:1080`) | ✓ — known intentional gap (`EXPLORER_STATE.md §1`, `#163` item 6) | +| Hover label text | `viewer.pointLabel` | ✗ ephemeral by design | +| Globe canvas zoom-watcher transient state | `viewer._clusterData`, `_clusterTotal`, `_baselineCounts` | ✗ derived from filters + camera | + +The **selected cluster** is the only meaningful selection-state gap. + +--- + +## 2. The cluster identity problem + +A clicked cluster's runtime identity (`:891`): + +```js +{ count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: 4 } +``` + +Three things make this harder than `pid`: + +1. **Resolution-dependent**: a cluster at H3 res 4 doesn't exist as a unit at res 6; it splits into smaller clusters. Reload at a different camera altitude → different H3 resolution → no cluster matches. +2. **Filter-dependent**: cluster aggregation depends on the active `?sources=`, `?material=`, etc. filters at click time. Reload with different filters → different aggregation, even at the same resolution. +3. **Source-faceted**: the cluster's `dominant_source` is the *majority* source in that cell. With filters applied, the dominant source can flip. + +So a URL that says "you clicked the cluster at (lat=33.27, lng=-86.24, res=4, source=SESAR)" is only meaningful if the URL *also* pins the resolution and filter state. Most of those filters are already URL-persisted (§1) — the missing pieces are the cluster identity itself and possibly the resolution if it isn't already implied by `#alt`. + +Tangentially: the resolution chosen by the explorer at any camera altitude is governed by the `zoomWatcher` cell. As long as that mapping is stable, `#alt` *implicitly* pins the resolution, but encoding the resolution explicitly is more robust to future changes. + +--- + +## 3. Encoding options + +Four candidate URL representations for cluster selection, with the `&pid=` +slot generalized. + +### Option A: separate cluster fields + +``` +#cluster_lat=33.2706&cluster_lng=-86.2375&cluster_res=4&cluster_source=SESAR +``` + +| pro | con | +|---|---| +| Mirrors the runtime `id` shape; trivial round-trip; greppable. | Four params; bloats hash; collisions risk if other features want `&cluster_*`. | + +### Option B: single packed string + +``` +#cluster=SESAR:4:33.2706,-86.2375 +``` + +| pro | con | +|---|---| +| One token; matches `#pid` ergonomically; format is documentable. | Custom parser; slightly less human-editable. | + +### Option C: H3 cell index + +``` +#h3=841a067ffffffff&cluster_source=SESAR +``` + +| pro | con | +|---|---| +| H3 cell index is a canonical 15-char hex token uniquely identifying the cell at a given resolution; resolution is *embedded* in the index. Joins to the cluster row are exact, not delta-windowed. | Requires the explorer to compute or read the H3 index from the data. The current parquet schema has `center_lat`/`center_lng` but I haven't confirmed whether the H3 cell index is available — needs a quick parquet-schema check before committing to this. | + +### Option D: unified `&sel=` field with type prefix + +``` +#sel=p:ark:/65665/abc (sample) +#sel=c:SESAR:4:33.2706,-86.2375 (cluster) +``` + +| pro | con | +|---|---| +| One field for "what's selected"; uniform handler; future-proofs other selection types. | Migration cost: existing `&pid=` URLs in the wild stop working unless we keep both. Backwards-compat shim isn't free. | + +--- + +## 4. Recommendation + +**Option B (single packed `&cluster=` field), keeping `&pid=` as-is.** + +Rationale: + +- One new token, one new parser, mirrors the cluster's runtime identity 1:1. No data-pipeline change. +- No backwards-compat tax. URLs with `&pid=` keep working unchanged. +- Cluster + sample selections are mutually exclusive in the runtime (`selectedPid` is cleared on cluster click), so the URL having at most one of `&pid=` / `&cluster=` matches that. +- Option C (H3 cell index) is *cleaner long-term* but needs a parquet-schema check + a possible data-build change. Worth doing if the H3 index is already there; defer if not. + +Concretely: + +```js +// extend _globeState +viewer._globeState = { + mode: 'cluster' | 'point', + selectedPid: string | null, + selectedCluster: { source, res, lat, lng } | null, // NEW +}; + +// readHash() addition +cluster: parseCluster(params.get('cluster')), // 'SESAR:4:33.2706,-86.2375' → object | null + +// buildHash() addition +const sc = gs.selectedCluster; +if (sc) params.set('cluster', `${sc.source}:${sc.res}:${sc.lat.toFixed(4)},${sc.lng.toFixed(4)}`); + +// cluster-click handler (:916-920) +v._globeState.selectedPid = null; +v._globeState.selectedCluster = { source: meta.source, res: meta.resolution, lat: meta.lat, lng: meta.lng }; +history.pushState(null, '', buildHash(v)); + +// hashchange / boot hydration (`:1733`, `:2167`) +if (state.cluster) { + // Re-fetch the same nearby-samples query the click handler runs (`:925-960`) + // by reconstructing meta from the URL token and calling updateClusterCard / nearbyQuery. + // Mutual exclusion: clear selectedPid. +} +``` + +Behavior on URL load: + +1. `&cluster=...` present, no `&pid=`: re-run the cluster card + nearby-samples query at boot. If the camera altitude maps to a different H3 resolution than the cluster's, *don't* try to highlight a different cell — just populate the side panel from the URL's frozen cluster identity. +2. `&pid=` present, no `&cluster=`: existing behavior, no change. +3. Both present: prefer `&pid=` (sample mode wins; cluster URL is stale). Drop `&cluster=` from a `replaceState` to clean up. +4. Neither: blank selection (current behavior). + +--- + +## 5. Open questions + +1. **H3 cell index in parquet?** If `oc_isamples_pqg_wide.parquet` (or the lite pre-aggregated cluster parquet) already carries the H3 index per-row, switch to Option C and the encoding becomes `&h3=841a067ffffffff&cluster_source=SESAR`. Worth a one-off `DESCRIBE` query to find out. *Action: 5-min check before implementation.* +2. **Cross-resolution behavior on load.** If you share a `&cluster=...&alt=...` URL and the recipient's window is wider/narrower → the alt → resolution mapping might pick a different res. Should the URL load *force* the resolution to match the cluster's, or just populate the side-panel and let the camera dictate the visible H3 layer? *Recommendation: just populate the side panel; don't override camera. The shared-link receiver wanted to see your sample list, not necessarily your H3 grid.* +3. **Does the side panel need a "this cluster's view is no longer live" hint** when the recipient's camera/filters cause the on-globe cluster to differ from the URL one? *Defer; revisit after implementation if it confuses people.* +4. **Backwards compat for `&pid=`**: keep forever. Anyone who shared a sample-link in the wild today should not have it break. +5. **Schema bump**: should this trip `v=1` → `v=2` in the hash? Probably no — `&cluster=` is purely additive and old clients ignore it. Reserve the version bump for breaking changes. + +--- + +## 6. Phasing + +- **Phase 1** *(this proposal)*: ship `&cluster=` per Option B. ~30-line patch in `explorer.qmd`. Update `EXPLORER_STATE.md §2` table to add the row. +- **Phase 2** *(deferred)*: if Option C turns out to be free (H3 cell index in the parquet), migrate `&cluster=` to `&h3=` and keep a one-version compat shim that accepts both. +- **Phase 3** *(deferred, only if needed)*: unified `&sel=` field per Option D. Only worth doing if a third selection type (e.g., region/polygon) appears. + +--- + +## 7. Acceptance for Phase 1 + +- [ ] `viewer._globeState.selectedCluster` field exists and is mutated by the cluster-click handler at `:916`. +- [ ] `buildHash()` writes `&cluster=` when `selectedCluster` is non-null. +- [ ] `readHash()` parses `&cluster=` into the same shape. +- [ ] Reloading a `&cluster=...` URL re-populates the side-panel cluster card + nearby-samples list with the same data the click would have produced. +- [ ] Sample-click clears `selectedCluster`; cluster-click clears `selectedPid`. Mutual exclusion preserved. +- [ ] `EXPLORER_STATE.md §2` table updated with the new row. +- [ ] One Playwright test: click cluster → verify URL contains `&cluster=` → reload → verify side panel re-populates. From 877afcd55434bec5b633a6c012df08576d2f4182 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Sat, 9 May 2026 09:11:50 -0700 Subject: [PATCH 2/2] docs: revise cluster URL proposal per Codex review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two material findings from Codex review of v1: 1. P2 — H3 cell index is already in the data, not unknown. The explorer's H3 summary queries already SELECT h3_cell at explorer.qmd:973 (phase1) and :1316 (loadRes); the cluster .id object simply drops it. The v1 doc treated this as an open question requiring a parquet schema check before committing to Option C. It isn't — Option C is feasible today. 2. P2 — Filter dependence was overstated. The H3 summary parquets only carry dominant_source. The code at :1706-1710 documents that material/context/object_type filters cannot affect cluster counts. Cluster identity reproduction only depends on ?sources=, which is already URL-persisted. Changes: - Recommendation switches from Option B (lossy lat/lng tuple) to Option C (&h3=, exact key join). - Filter-dependence section narrowed to source-filter only. - Phasing simplified: Phase 1 ships Option C directly; the previously-planned Phase 2 compat shim is dropped (no lossy intermediate form to migrate from). - Acceptance criteria updated to reflect h3_cell threading and &h3= rather than &cluster=. Doc-only — no code changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- EXPLORER_CLUSTER_URL_PROPOSAL.md | 115 ++++++++++++++++++------------- 1 file changed, 67 insertions(+), 48 deletions(-) diff --git a/EXPLORER_CLUSTER_URL_PROPOSAL.md b/EXPLORER_CLUSTER_URL_PROPOSAL.md index c855eef..e0c182b 100644 --- a/EXPLORER_CLUSTER_URL_PROPOSAL.md +++ b/EXPLORER_CLUSTER_URL_PROPOSAL.md @@ -4,6 +4,8 @@ Companion to [`EXPLORER_STATE.md`](./EXPLORER_STATE.md). Audit of what selection the Explorer URL captures today, and a proposal for adding cluster-selection state so a URL alone can replay "I clicked this dot and got these samples." +**Recommendation (revised after Codex review)**: encode cluster selection as `&h3=` in the URL hash, using the H3 cell index that the explorer already SELECTs from the parquet (`explorer.qmd:973`, `:1316`). Exact key join, no lossy lat/lng tuple, no data-pipeline change. + All file:line references are against `explorer.qmd` at commit `e0043c8` (post-#180 polish PR). @@ -54,15 +56,14 @@ A clicked cluster's runtime identity (`:891`): { count, source: row.dominant_source, lat: row.center_lat, lng: row.center_lng, resolution: 4 } ``` -Three things make this harder than `pid`: +Two things make this harder than `pid` (revised after Codex review of v1): -1. **Resolution-dependent**: a cluster at H3 res 4 doesn't exist as a unit at res 6; it splits into smaller clusters. Reload at a different camera altitude → different H3 resolution → no cluster matches. -2. **Filter-dependent**: cluster aggregation depends on the active `?sources=`, `?material=`, etc. filters at click time. Reload with different filters → different aggregation, even at the same resolution. -3. **Source-faceted**: the cluster's `dominant_source` is the *majority* source in that cell. With filters applied, the dominant source can flip. +1. **Resolution-dependent**: a cluster at H3 res 4 doesn't exist as a unit at res 6; it splits into smaller cells. Reload at a different camera altitude → different H3 resolution → no cluster matches. +2. **Source-filter-dependent only**: cluster aggregation depends on the active `?sources=` filter. The H3 summary parquets explicitly carry only `dominant_source` — the cluster code at `explorer.qmd:1706-1710` documents that `material` / `context` / `object_type` filters **cannot** affect cluster counts, only sample-level views. So cluster identity is reproducible if `?sources=` is pinned. -So a URL that says "you clicked the cluster at (lat=33.27, lng=-86.24, res=4, source=SESAR)" is only meaningful if the URL *also* pins the resolution and filter state. Most of those filters are already URL-persisted (§1) — the missing pieces are the cluster identity itself and possibly the resolution if it isn't already implied by `#alt`. +The H3 cell index is **canonical**: a single 15-character hex value (e.g. `841a067ffffffff`) that uniquely identifies a cell at a specific resolution. The resolution is *embedded* in the index — no separate field needed. The explorer's H3 summary queries (`explorer.qmd:973`, `:1316`) already SELECT `h3_cell`; the cluster `.id` object simply doesn't carry it forward. **The "is the H3 index available?" open question (v1 doc OQ1) is answered: yes, no data-pipeline change required.** -Tangentially: the resolution chosen by the explorer at any camera altitude is governed by the `zoomWatcher` cell. As long as that mapping is stable, `#alt` *implicitly* pins the resolution, but encoding the resolution explicitly is more robust to future changes. +So a URL that says "you clicked H3 cell `841a067ffffffff`" plus the existing `?sources=` filter state fully reproduces the cluster the user saw — no lat/lng drift, no resolution param, exact join into the parquet row. --- @@ -81,7 +82,7 @@ slot generalized. |---|---| | Mirrors the runtime `id` shape; trivial round-trip; greppable. | Four params; bloats hash; collisions risk if other features want `&cluster_*`. | -### Option B: single packed string +### Option B: single packed lat/lng tuple — REJECTED ``` #cluster=SESAR:4:33.2706,-86.2375 @@ -89,23 +90,29 @@ slot generalized. | pro | con | |---|---| -| One token; matches `#pid` ergonomically; format is documentable. | Custom parser; slightly less human-editable. | +| One token; matches `#pid` ergonomically; format is documentable. | **Lossy** — relies on lat/lng comparison rather than exact key join. The H3 cell index is already in the runtime data (see Option C); this option throws it away for no reason. Custom parser; coordinate rounding could miss the row. | -### Option C: H3 cell index +### Option C: H3 cell index — RECOMMENDED ``` -#h3=841a067ffffffff&cluster_source=SESAR +#h3=841a067ffffffff ``` | pro | con | |---|---| -| H3 cell index is a canonical 15-char hex token uniquely identifying the cell at a given resolution; resolution is *embedded* in the index. Joins to the cluster row are exact, not delta-windowed. | Requires the explorer to compute or read the H3 index from the data. The current parquet schema has `center_lat`/`center_lng` but I haven't confirmed whether the H3 cell index is available — needs a quick parquet-schema check before committing to this. | +| H3 cell index is a canonical 15-char hex token uniquely identifying the cell at a specific resolution; resolution is *embedded* in the index. Joins to the cluster row are exact (`WHERE h3_cell = ?`), not delta-windowed. The `?sources=` filter (already URL-persisted) covers source-state reproducibility. | None significant — the index is already SELECTed at `explorer.qmd:973` and `:1316`; only the cluster `.id` object needs to carry it. | + +**Note**: `dominant_source` does *not* need to be in the URL. It's a *derived* attribute of the cluster row, looked up by `h3_cell` at hydration time. The source-filter state (`?sources=`) is what matters for reproducing the same aggregation. + +### Option A: separate cluster fields + +(see above) ### Option D: unified `&sel=` field with type prefix ``` -#sel=p:ark:/65665/abc (sample) -#sel=c:SESAR:4:33.2706,-86.2375 (cluster) +#sel=p:ark:/65665/abc (sample) +#sel=c:841a067ffffffff (cluster, h3-form) ``` | pro | con | @@ -116,78 +123,90 @@ slot generalized. ## 4. Recommendation -**Option B (single packed `&cluster=` field), keeping `&pid=` as-is.** +**Option C (`&h3=`), keeping `&pid=` as-is.** + +Revised from v1 of this doc after Codex review confirmed `h3_cell` is already in the H3 summary parquets (queried at `explorer.qmd:973` and `:1316`) — the cluster `.id` just doesn't carry it forward. Rationale: -- One new token, one new parser, mirrors the cluster's runtime identity 1:1. No data-pipeline change. -- No backwards-compat tax. URLs with `&pid=` keep working unchanged. -- Cluster + sample selections are mutually exclusive in the runtime (`selectedPid` is cleared on cluster click), so the URL having at most one of `&pid=` / `&cluster=` matches that. -- Option C (H3 cell index) is *cleaner long-term* but needs a parquet-schema check + a possible data-build change. Worth doing if the H3 index is already there; defer if not. +- **Exact lookup, not lossy**: `WHERE h3_cell = ''` is a primary-key join. The lat/lng-tuple alternative (Option B) requires `WHERE` + range comparison and is fragile to coordinate rounding. +- **Single token**: 15 hex chars; resolution implicit; no parser beyond `params.get('h3')`. +- **No data-pipeline change**: just thread `h3_cell` into the runtime `id` object at the two `add()` sites. +- **No backwards-compat tax**: `&pid=` URLs keep working unchanged. +- **Cluster + sample mutual exclusion** matches the runtime invariant (`selectedPid` is cleared on cluster click). Concretely: ```js -// extend _globeState +// 1. Carry h3_cell into the runtime id (explorer.qmd:987 and :1331) +viewer.h3Points.add({ + id: { h3_cell: row.h3_cell, count, source: row.dominant_source, + lat: row.center_lat, lng: row.center_lng, resolution: 4 }, + ... +}); + +// 2. Extend _globeState (explorer.qmd:821) viewer._globeState = { mode: 'cluster' | 'point', selectedPid: string | null, - selectedCluster: { source, res, lat, lng } | null, // NEW + selectedH3: string | null, // NEW — the h3_cell hex string }; -// readHash() addition -cluster: parseCluster(params.get('cluster')), // 'SESAR:4:33.2706,-86.2375' → object | null +// 3. readHash() addition (:615) +h3: params.get('h3') || null, -// buildHash() addition -const sc = gs.selectedCluster; -if (sc) params.set('cluster', `${sc.source}:${sc.res}:${sc.lat.toFixed(4)},${sc.lng.toFixed(4)}`); +// 4. buildHash() addition (:629) +const gs = v._globeState; +if (gs.selectedH3) params.set('h3', gs.selectedH3); -// cluster-click handler (:916-920) +// 5. Cluster-click handler (:916-920) v._globeState.selectedPid = null; -v._globeState.selectedCluster = { source: meta.source, res: meta.resolution, lat: meta.lat, lng: meta.lng }; +v._globeState.selectedH3 = meta.h3_cell; history.pushState(null, '', buildHash(v)); -// hashchange / boot hydration (`:1733`, `:2167`) -if (state.cluster) { - // Re-fetch the same nearby-samples query the click handler runs (`:925-960`) - // by reconstructing meta from the URL token and calling updateClusterCard / nearbyQuery. +// 6. Hash hydration (:1733, :2167) +if (state.h3) { + // Reconstruct cluster meta with: SELECT * FROM read_parquet(...) WHERE h3_cell = ? + // Then call updateClusterCard(meta) + the nearby-samples query at :925-960. // Mutual exclusion: clear selectedPid. } ``` Behavior on URL load: -1. `&cluster=...` present, no `&pid=`: re-run the cluster card + nearby-samples query at boot. If the camera altitude maps to a different H3 resolution than the cluster's, *don't* try to highlight a different cell — just populate the side panel from the URL's frozen cluster identity. -2. `&pid=` present, no `&cluster=`: existing behavior, no change. -3. Both present: prefer `&pid=` (sample mode wins; cluster URL is stale). Drop `&cluster=` from a `replaceState` to clean up. +1. `&h3=...` present, no `&pid=`: query the parquet for that cell, populate side panel + nearby-samples list. Camera/H3-resolution layer left as-is — see OQ2. +2. `&pid=` present, no `&h3=`: existing behavior, no change. +3. Both present: prefer `&pid=` (sample mode wins; cluster URL is stale). Drop `&h3=` via `replaceState` to clean up. 4. Neither: blank selection (current behavior). --- ## 5. Open questions -1. **H3 cell index in parquet?** If `oc_isamples_pqg_wide.parquet` (or the lite pre-aggregated cluster parquet) already carries the H3 index per-row, switch to Option C and the encoding becomes `&h3=841a067ffffffff&cluster_source=SESAR`. Worth a one-off `DESCRIBE` query to find out. *Action: 5-min check before implementation.* -2. **Cross-resolution behavior on load.** If you share a `&cluster=...&alt=...` URL and the recipient's window is wider/narrower → the alt → resolution mapping might pick a different res. Should the URL load *force* the resolution to match the cluster's, or just populate the side-panel and let the camera dictate the visible H3 layer? *Recommendation: just populate the side panel; don't override camera. The shared-link receiver wanted to see your sample list, not necessarily your H3 grid.* -3. **Does the side panel need a "this cluster's view is no longer live" hint** when the recipient's camera/filters cause the on-globe cluster to differ from the URL one? *Defer; revisit after implementation if it confuses people.* -4. **Backwards compat for `&pid=`**: keep forever. Anyone who shared a sample-link in the wild today should not have it break. -5. **Schema bump**: should this trip `v=1` → `v=2` in the hash? Probably no — `&cluster=` is purely additive and old clients ignore it. Reserve the version bump for breaking changes. +1. ~~**H3 cell index in parquet?**~~ **Answered**: yes. `h3_cell` is already SELECTed at `explorer.qmd:973` (phase1) and `:1316` (loadRes); only the cluster `.id` object needs to carry it forward. +2. **Cross-resolution behavior on load.** If you share `&h3=&alt=...` and the recipient's window altitude maps to a different H3 resolution than the cell's, should the URL load *force* the resolution to match the cell's, or just populate the side-panel and leave the visible H3 layer alone? *Recommendation: populate the side panel only. The shared-link receiver wanted to see your sample list, not necessarily your grid.* +3. **Does the side panel need a "this cluster's view is no longer live" hint** when the recipient's camera/filters cause the on-globe rendering to differ from the URL one? *Defer; revisit after implementation if it confuses people.* +4. **Backwards compat for `&pid=`**: keep forever. Existing sample-share links must not break. +5. **Schema bump**: should this trip `v=1` → `v=2` in the hash? Probably no — `&h3=` is purely additive and old clients ignore unknown params. Reserve the version bump for breaking changes. --- ## 6. Phasing -- **Phase 1** *(this proposal)*: ship `&cluster=` per Option B. ~30-line patch in `explorer.qmd`. Update `EXPLORER_STATE.md §2` table to add the row. -- **Phase 2** *(deferred)*: if Option C turns out to be free (H3 cell index in the parquet), migrate `&cluster=` to `&h3=` and keep a one-version compat shim that accepts both. -- **Phase 3** *(deferred, only if needed)*: unified `&sel=` field per Option D. Only worth doing if a third selection type (e.g., region/polygon) appears. +- **Phase 1** *(this proposal)*: ship `&h3=` per Option C. ~25-line patch in `explorer.qmd` (carry `h3_cell` through the runtime `id`; extend `_globeState`, `readHash`, `buildHash`, cluster-click handler, hash hydration). Update `EXPLORER_STATE.md §2` table to add the row. +- **Phase 2** *(only if needed)*: unified `&sel=` field per Option D. Worth doing only if a third selection type (e.g., region/polygon) appears. + +The previous Phase 2 (compat shim from `&cluster=` to `&h3=`) is dropped — by going straight to Option C we never ship the lossy intermediate form. --- ## 7. Acceptance for Phase 1 -- [ ] `viewer._globeState.selectedCluster` field exists and is mutated by the cluster-click handler at `:916`. -- [ ] `buildHash()` writes `&cluster=` when `selectedCluster` is non-null. -- [ ] `readHash()` parses `&cluster=` into the same shape. -- [ ] Reloading a `&cluster=...` URL re-populates the side-panel cluster card + nearby-samples list with the same data the click would have produced. -- [ ] Sample-click clears `selectedCluster`; cluster-click clears `selectedPid`. Mutual exclusion preserved. +- [ ] `h3_cell` carried through the runtime cluster `id` at both `add()` sites (`:987`, `:1331`). +- [ ] `viewer._globeState.selectedH3` field exists and is mutated by the cluster-click handler at `:916`. +- [ ] `buildHash()` writes `&h3=` when `selectedH3` is non-null. +- [ ] `readHash()` parses `&h3=` as a hex string. +- [ ] Reloading a `&h3=` URL re-populates the side-panel cluster card + nearby-samples list with the same data the click would have produced (exact `WHERE h3_cell = ?` join). +- [ ] Sample-click clears `selectedH3`; cluster-click clears `selectedPid`. Mutual exclusion preserved. - [ ] `EXPLORER_STATE.md §2` table updated with the new row. -- [ ] One Playwright test: click cluster → verify URL contains `&cluster=` → reload → verify side panel re-populates. +- [ ] One Playwright test: click cluster → verify URL contains `&h3=` → reload → verify side panel re-populates.