Skip to content

Latest commit

 

History

History
669 lines (560 loc) · 45.8 KB

File metadata and controls

669 lines (560 loc) · 45.8 KB

Explorer State Contract

Doc-only contract for explorer.qmd. Locks down where every piece of explorer state lives, who owns it, when it gets written, and what the cross-filter rules are. Resolves #164; unblocks the items in #163.

Direction: imperative + URL-as-canonical-state. OJS cells are used as a DAG-aware bootstrap (run viewer once, run phase1 after viewer is ready, etc.), not as a reactive UI runtime. All user-driven UI updates go through writeQueryState()history.replaceState and direct DOM mutation. Reading DOM checkboxes is the source of truth for facet state inside SQL builders; the URL is the source of truth across reloads.

All file:line references below are against explorer.qmd at commit 94e7674 (the tree this doc was originally written against). The inventory has had targeted edits as later changes land — most notably the mockup-v1 PR (#200) which removed the Globe/Table view toggle, relocated search into an in-map overlay, added a sidebar search input that mirrors the in-map one, and made the samples table a permanent surface below the globe. See §6 "Mockup-v1 addendum" for the full delta. Line numbers may not match exactly; the contract is the source of truth, not the line citations.


1. URL query params (location.search)

field owner default URL repr hydration site write-back trigger validation notes
search DOM #sampleSearch value omitted raw string applyQueryToSearch() at start of phase1 writeQueryState() called from doSearch() trim only; min 2 chars enforced at search time, not in URL written even on no-result searches. The mirrored #sampleSearchSidebar input was removed in #266
sources DOM #sourceFilter checkboxes omitted (= all 4 checked) CSV of SOURCE_VALUES ∩ user-checked applyQueryToSourceFilter() at start of phase1 (:938) writeQueryState() from source filter change (:1620) filtered by SOURCE_VALUES allowlist (:407); param removed when all 4 checked (:449) empty (zero checked) renders as &sources= and yields 1=0 predicate (:379)
material DOM #materialFilterBody checkboxes omitted (= no filter) CSV of full URIs applyQueryToFacetFilters() at end of facetFilters (:1061) writeQueryState() from handleFacetFilterChange (:1642) none — checkbox value already constrained by render empty checked set ⇒ param removed (:459)
context DOM #contextFilterBody checkboxes omitted CSV of full URIs same as material same as material none same
object_type DOM #objectTypeFilterBody checkboxes omitted CSV of full URIs same as material same as material none same
view removed in mockup-v1 (#200) The Globe/Table toggle is gone — the samples table is now permanent below the globe. writeQueryState() does params.delete('view') to canonicalize legacy bookmarks. See §6 "Mockup-v1 addendum"
search_scope local closure _searchScope in zoomWatcher omitted (= world) area only; absent ⇒ world _searchScope hydrated at top of zoomWatcher from params.get('search_scope') persistSearchScope() from doSearch() and button clicks exact match 'area' (the sidebar search input that always submitted world was removed in #266)
page inner closure let page = 0 in tableView not in URL resets to 0 on refreshTable(); ±1 on prev/next clamped to [0, totalPages-1] #163 item 6 — table page is intentionally not URL state today; if/when added, must coexist with the cross-filter contract below
perf — (read-only feature flag) omitted 1 to enable perfPanel cell reads (:1921-1922) never written === '1' exact match never round-tripped; safe to add other tail params

Quarto site-search collision

?q= is hijacked by Quarto's site-wide search and stripped via replaceState (see comment at :414-416). That is why the explorer uses ?search= for the sample text query. Don't migrate to q.


2. URL hash params (location.hash)

The hash is the camera/deep-link channel. Always written together by buildHash(viewer) (:597-613), always read together by readHash() (:583-595).

field owner default hash repr hydration site write-back trigger validation notes
v constant '1' (always written) v=1 readHash() returns `parseInt() 0 (:586`) buildHash always sets '1' (:601)
lat viewer.camera.positionCartographic absent 4-decimal degrees readHash() (:587); applied in viewer once postRender (:797-812) camera-change debounced 600ms (:1700-1702, replaceState); enter/exit point mode (:1463, :1474, pushState); sample/cluster click (:861, :888, pushState); share button (:1763, replaceState) clamped [-90, 90] (:587) written together with lng, alt
lng same absent 4-decimal degrees same same clamped [-180, 180] (:588)
alt same null ⇒ falls back to 20000000 (:801) integer meters same same clamped [100, 40_000_000] (:589)
heading viewer.camera.heading 0 degrees, 1-decimal same same; only written if |heading| > 1 (:607) clamped [0, 360] (:590)
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 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('<decimal>' AS UBIGINT) AND <sourceFilterSQL> 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

viewer._suppressHashWrite (boot at true at :791, cleared at :1904) prevents the camera-change handler from rewriting the hash while the hashchange handler is mid-flight. The _suppressTimer (:792, :1725) re-arms after a 2-second flight settles. Do not remove this flag without re-deriving the camera→hash→camera echo prevention.

back/forward listener

window.addEventListener('hashchange', ...) at :1708 does the inverse of buildHash: flies the camera, restores the selected pid, and toggles enterPointMode(false) / exitPointMode(false) (the false suppresses a nested pushState).


3. DOM-as-state

State that lives only on the page tree. Authoritative because some of it predates the store-on-viewer pattern and isn't worth migrating.

location role written by read by notes
document.body.classList['table-view-active'] removed in mockup-v1 (#200) The view marker class is gone with the Globe/Table toggle. The samples table is permanent below the globe; isTableViewActive() and setView() were deleted
data-facet, data-value on .facet-row and .facet-count facet selectors for in-place count mutation rendered in renderFilter and the static source legend applyFacetCounts() rebuilding the HTML would lose mid-interaction selections; data-* attrs are why we mutate counts in place
data-lat, data-lng, data-pid on .sample-row click-to-fly payload for search/nearby results rendered in doSearch() search-row click handler the nearby-samples list does not have data-* today; click-to-fly only works from the search list
data-pid on .samples-table tbody tr click-to-select payload for the permanent table rendered in tableView renderTable() table-row click handler (same ceremony as the search-row click — see §6 mockup-v1 addendum) per-PID lookup uses pageRowsByPid: Map cached per page at loadPage() time (table v2); only the current page is in memory
tr.selected on .samples-table "this row is the current sample selection" visual marker table-row click handler (add); next click on a different row (remove); next renderTable() reflects current viewer._globeState.selectedPid CSS only derived; do not read. The globe → table direction is not live (only repaints on the next page load)
#tableContainer.is-loading + aria-busy="true" "table query in flight" marker setLoading(true/false) in tableView (table v2) CSS dim (.samples-table opacity 0.6); screen readers added in table v2 follow-up to PR #200 for stale-while-loading UX
.recomputing on .facet-count transient "loading" styling during cross-filter recompute markFacetCountsRecomputing() (:570) applyFacetCounts() clears it (:562) UI-only; not state in the persistence sense
.zero on .facet-row "value has zero count under current filters" styling applyFacetCounts() (:565) CSS only derived; do not read
.disabled on #sourceFilter .legend-item unchecked source visual updateSourceLegendState() (:395-400) CSS only derived from checkbox checked; do not read

DOM input elements (the four facet checkbox bodies + #sampleSearch) are the source of truth for getActiveSources(), getCheckedValues(), and the search input. SQL builders read #sampleSearch directly each call. (The mirrored #sampleSearchSidebar input was removed in #266 — #sampleSearch is the only search box; the historical mirror design is in §6.) The #maxSamples input and the getTableMaxSamples() / clampTableMaxSamples() helpers were removed in the table v2 follow-up — the samples table now paginates server-side via DuckDB LIMIT/OFFSET instead of fetching up to 25K rows up-front.


4. Widget-internal state (viewer.* / window.*)

Set on the Cesium Viewer instance during the viewer cell so it survives across cells without becoming a separate OJS reactive value.

field type set at read at role
viewer._globeState `{ mode: 'cluster' 'point', selectedPid: string null }` init :789; mutated :860, :887, :1460, :1470, :1734, :1754, :1875
viewer._initialHash readHash() snapshot init :790 viewer first-frame once (:797); zoomWatcher boot deep-link (:1873) preserves boot-time hash for late hydration
viewer._suppressHashWrite bool init true :791; cleared :1904; re-set/cleared in hashchange (:1712, :1725) camera-change handler (:1700) echo-loop guard
viewer._suppressTimer timer handle :792, :1713, :1725 cleared in :1713 re-arms _suppressHashWrite after camera flight
viewer._clusterData Array<row> :964 (phase1), :1305 (loadRes) countInViewport (:1342); zoom recompute (:1690) viewport count cache
viewer._clusterTotal { clusters, samples } :965, :1306 exitPointMode (:1481); zoom recompute (:1693) totals for the "in view / loaded" stat
viewer._baselineCounts { source: Map, material: Map, context: Map, object_type: Map } :1030-1035 applyFacetCounts() (:552) unfiltered facet counts; rendered when no cross-filter is active
viewer.h3Points Cesium PointPrimitiveCollection :815 cluster mode rendering
viewer.samplePoints Cesium PointPrimitiveCollection :818 point mode rendering
viewer.pointLabel Cesium label entity :823 mouse-move handler (:836-848) hover tooltip
viewer._selGen int bumped by every freshSelectionToken(viewer) call (top-level helper, see invariant below) snapshot captured by each handler that mutates selection freshness counter; see invariant below
window.refreshSamplesTable () => Promise<void> :1238 external (debug / Playwright) not used by other cells; safe to keep or remove

Async-selection invariant

Any async work that updates viewer._globeState, the URL hash, or the side-panel DOM must check freshness after every await. The freshSelectionToken(viewer) helper (defined at top level alongside readHash / buildHash so both the viewer-cell click handler and the zoomWatcher-cell handlers can reach it) is the primitive: each user-input event handler that touches selection (cluster/sample click, hashchange, source-filter toggle, boot deep-link) calls it once at start to bump _selGen and capture an isStale() closure; every subsequent await is followed by if (isStale()) return; before any state/URL/DOM mutation. Pass isStale into nested helpers (hydrateClusterUI's second param) so their internal awaits also bail before touching the DOM.

This invariant exists because there's no central "selection store" — selection state lives in _globeState, the URL hash, and the side-panel DOM, and four different paths (click, hashchange, filter, boot) write to all three. Without the freshness check, a slow earlier handler can repaint the side panel for a selection the user has already moved off of. Issue #187 has the post-mortem on the 6-round Codex review that motivated extracting the primitive.

_urlParamsHydrated — confirmed gone

Grep for _urlParamsHydrated in explorer.qmd returns no hits. The flag from PR #159's first cut was removed; the new contract is "URL → DOM hydration runs exactly once per cell that owns the corresponding DOM, gated only by the OJS DAG (phase1 ⇒ source/search hydration; facetFilters ⇒ facet hydration). Mockup-v1 removed the tableView ⇒ view hydration step along with the ?view= URL param."


5. OJS cell graph

Document order, with declared dependencies and side effects. The cells form a fan-out from viewer + db:

Cesium-token    [pure global mutation]
constants/helpers  [pure; defines URLs, palettes, ~40 helper functions]
db                 [DuckDBClient.of()]
viewer             [creates Cesium viewer; reads readHash() once]
  └── phase1       [needs viewer + db; runs URL→DOM hydration for search + sources]
        └── facetFilters    [needs phase1; loads vocab + summaries; sets _baselineCounts; runs URL→DOM hydration for facet checkboxes]
              ├── tableView    [needs facetFilters; calls refreshTable() unconditionally on boot — no URL hydration]
              └── zoomWatcher  [needs facetFilters; registers ALL change handlers; runs deep-link pid restore]
                    └── perfPanel  [opt-in; needs phase1; renders perf panel if ?perf=1]
cell line implicit deps DOM mutation event listeners registered URL writes
Cesium token :328
constants/helpers :333
db :759
viewer :773 #cesiumContainer mounts globe scene.postRender×2; mouse-move; left-click pushState (sample/cluster click)
phase1 :930 viewer, db #sourceFilter (via hydration); stats DOM
facetFilters :979 phase1, db #materialFilterBody, #contextFilterBody, #objectTypeFilterBody; facet count text
tableView :1071 facetFilters #tableContainer, #samplesTable, tr.selected class prev/next; max input; change on all four facet bodies; table-row clicks replaceState via buildHash from table-row click (sets #pid directly, mirrors sample-mode globe click)
zoomWatcher :1246 phase1, facetFilters, db facet count text; stats; phase msg; sample card; samples list source filter change; material/context/object_type change; camera.changed; camera.moveEnd (sub-threshold pan settle, #205); window hashchange; share button; search button; in-map search input keydown; sidebar search input input (mirror) and keydown (world-scope submit) pushState and replaceState via buildHash (camera changed/moveEnd, mode flip, sample fly, share button); replaceState via writeQueryState (filter changes, search submit)
tableView (post table-v2 viewport coupling) facetFilters, viewer also listens to viewer.camera.moveEnd to re-scope table to viewport bbox
perfPanel :1910 phase1 #perfPanel floating div close button

Note that two cells register change listeners on the four facet container elements: tableView calls refreshTable() unconditionally (the table is permanent post-mockup-v1, so there's no view gate); zoomWatcher reloads the globe and debounces the cross-filter count refresh. Both listeners fire on every facet change; this is intentional (each cell handles its own concerns) but is the single most "magical" coupling in the file — touch with care.


6. Search-semantics decision

The issue originally presented two options (A) global filter and (B) side-panel lookup. After Codex review on #165, we committed to a sharper third framing: (C) side-panel lookup with result-pin overlay, which is a refinement of (B) — the backend is unchanged (search does not alter cluster/sample/facet data sources), but the UI surface gains a temporary point-overlay visualization of the matching samples.

What "search" does today (doSearch at :1782-1859)

Reads #sampleSearch.value. Runs an ILIKE-based DuckDB query against lite_url over label and place_name, with sourceFilterSQL() and facetFilterSQL() AND'd in. Renders up to 50 ranked results into #searchResults (count) and #samplesSection (list). Click a result row to fly the camera. The map clusters/samples are not filtered by the search text. Facet counts are not filtered by the search text. The URL search param is written via writeQueryState() (:1786, :1789).

That is option (B) verbatim, and it matches the facet-count fix Codex landed in #158.

The three options

  • (A) Global filter. Active search restricts the map layer, table, and facet counts to the matching subset. Forces cluster mode to drop to point mode whenever a search is active (H3 summaries are not text-indexable). Couples search semantics to camera/mode state. Rejected.
  • (B) Side-panel lookup. Search populates the side panel only; map + facet counts ignore it. Today's behavior. Leaves #163 item 4 as a UX wart (zero results + populated map). Insufficient.
  • (C) Side-panel lookup with result-pin overlay. Search populates the side panel and renders a temporary point-overlay of the matching samples on the globe — independent of the H3 cluster layer and the facet counts. The cluster layer remains accurate (no facet-aware text indexing required); the overlay visualizes "your search matched these samples, here are their locations." Cleared when the user clears the search. Adopted.

Decision: (C) side-panel lookup with result-pin overlay

What stays the same as (B). The backend / data-source contract is unchanged from option (B):

  1. The H3 summary parquets carry only dominant_source, sample_count, and centroid coords. Cluster mode is not text-aware.
  2. The cross-filter facet-count contract landed in #158 still excludes search from the count predicate.
  3. viewer._globeState does not gain a search-active mode; the cluster primitive collection is unaffected.

What's new in (C). A third primitive collection on the viewer: viewer.searchResultPoints (Cesium PointPrimitiveCollection), styled distinctly from cluster and sample-mode points (e.g., outlined ring rather than filled dot, distinct color). Owned by zoomWatcher's search handler, not the cluster/sample-mode handlers. Lifecycle:

  • Populated by doSearch() after results are computed. One pin per result, positioned at (longitude, latitude, 0). Opt-in to picking (clicking a pin behaves like clicking a sample-mode point: updateSampleCard() + pid hash write).
  • Cleared by:
    • User clears the search input (or ?search= is removed from URL).
    • User triggers a new search (overlay is replaced with new results).
  • Shown in both globe cluster mode and point mode. The cluster layer remains accurate; the overlay is layered above as visual answer to "where did my search land geographically."

Why (C) over (B).

  • Solves #163 item 4 cleanly: zero results render zero pins (and the populated map clusters remain visibly not the search results, distinguishable by styling). Non-zero results show a coherent visual answer.
  • Preserves the "imperative globe; URL-canonical state" framing — cluster and facet behavior is unaffected, no reactive cascade.
  • Doesn't conflate "search matched these samples" with "active filter" (which is what option A does); the user retains source/facet/view as independent orienting controls.

Costs of (C) we accept.

  • One additional Cesium primitive collection. The viewer cell at :773 needs a v.searchResultPoints = new Cesium.PointPrimitiveCollection() alongside the existing h3Points and samplePoints.
  • One additional data-pid data path: the search-result pin click handler duplicates the sample-mode click handler. Acceptable; both paths are small.

Implementation rules for option (C)

These are not negotiable defaults during implementation:

concern rule
Pin count cap maximum 50 pins, matching the existing search result LIMIT 50 (the displayed result set size). The full match set may exceed 50; the substrate query truncates to top-K before pin rendering. Pin count therefore equals min(50, total_matches), never more, never fewer
Z-order search-result pins render above both H3 cluster points and sample-mode points. Implementation: add searchResultPoints to scene.primitives after h3Points and samplePoints
Pin styling hollow ring with bright outline (distinct from cluster filled-dot and sample-mode small filled-dot). Color follows the source palette (SOURCE_COLORS) so a glance still tells you which source matched. Pixel size larger than sample-mode pins (e.g., 8 vs 6) so the overlay reads as primary
Hover label identical pattern to existing pointLabel handler — meta.label || meta.pid, source badge color
Click behavior identical to sample-mode click: updateSampleCard() + pid hash write. Single click handler can dispatch on meta.type === 'searchResult' vs 'sample'
Camera fit-to-bounds only fit-to-bounds when the result-set lat/lng extent < 30° × 30°. Otherwise: fly to top-1 result at altitude 200000 (same as today's first-result behavior). The 30° rule prevents a globally distributed result set from triggering a zoom-out to a near-globe view, which would be disorienting and wash out the cluster context
Lifecycle populated immediately after results return; cleared on (a) search input cleared by user, (b) ?search= removed from URL, (c) new search submitted (replaces the old overlay)

Result-set shape acceptance cases

Implementation must visually verify the four shape cases:

case description expected behavior
zero xyzzyqqqplugh no pins; side-panel says "No results"; cluster layer + facets unaffected; camera not flown
one a pid-specific search that hits exactly one sample one pin; fly-to-result at standard altitude; side panel shows the single result
local-many pottery Cyprus (results clustered in one region) up to 50 pins, lat/lng extent < 30°×30° → fit-to-bounds zoom
global-many basalt (results spread across multiple continents) up to 50 pins, extent ≥ 30°×30° → fly to top-1 at standard altitude; pins remain visible at multiple zoom levels via the cluster-overlay scaleByDistance

State-inventory addendum for (C)

The state contract grows a small amount:

location role written by read by notes
viewer.searchResultPoints Cesium PointPrimitiveCollection for search-result pins zoomWatcher (search handler) mouse-move + click handlers new collection; lifecycle tied to ?search= non-empty
viewer._searchResults (Array) array of result rows currently rendered as pins zoomWatcher (search handler) future "fit-to-bounds" logic optional cache; can be dropped if not needed

URL/hash params are unchanged from (B). The DOM-as-state inventory is unchanged.

Note for parallel investigation. A separate workstream (Codex, May 8) is auditing full-text-search status, indexing options, and speed. The (C) decision is intentionally orthogonal to the backend — which UI surface displays the matches is independent of how the matches are computed. If that investigation recommends switching the backend (e.g., from in-browser ILIKE → static-Parquet inverted index → hosted-search service), (C) is compatible with all of them.

Light-path addendum: two-button scope selection (#178, 2026-05-08)

Hana's mockup (Figma 213:394) proposed a two-button search UI: "Search Selected Areas" (viewport-scoped) and "Search Entire World" (full-corpus). Implemented as a Light extension of (C), not a revisit of the A/B/C decision:

  • "Search Entire World" runs the existing (C) full-corpus side-panel lookup with result-pin overlay. Behavior unchanged from the contract above. SQL shape: CTE over sample_facets_v2 → top-50 → LEFT JOIN to samples_map_lite for display coords (samples without coords still appear; lat/lng are null).
  • "Search Selected Areas" runs the same text predicate but with a different SQL shape: INNER JOIN samples_map_lite inside the candidate selection, viewport BETWEEN predicate applied before ORDER BY ... LIMIT 50. This is critical — applying viewport after the global top-50 produces false zeroes (the global top-50 is concentrated in a few hot regions; a Sudan-area pottery query would return zero even though Sudan has plenty of pottery hits). Dateline-crossing is split into two longitude ranges.
  • URL state gains ?search_scope=area|world; default world, omitted from URL when default. Hydrated on boot; written by persistSearchScope().
  • Result-pin overlay still applies in both modes — pin coordinates reflect what was found, viewport-scope just narrows the candidate set.
  • Auto-fly to the first result is suppressed in area mode (the user is already at the area they care about; flying would zoom in and disorient).
  • Area mode requires coordinates by definition, so the INNER JOIN drops samples that have facets but no samples_map_lite row. World mode keeps them (via LEFT JOIN) since coord-less samples are still legitimate text matches.

A future Heavy revisit may rethink (A) global-filter semantics if usage data shows users expect the map and facets to update with search. That decision is deferred until #170-#172 land.

Mockup-v1 addendum: in-map overlay, sidebar mirror, permanent table (#200, 2026-05)

A coordinated UI refactor aligning the explorer with Hana's wireframe. Built as a single PR sequenced into independently revertable commits. All changes are UI-relocation / surface-additive; the data/query contract is unchanged from the (C) decision + #178 light path above.

M-1A — Search controls relocated into an in-map overlay. The search input, the two scope buttons (Search Selected Areas / Search Entire World), help text, and #searchResults count moved from a top-of-page .explorer-controls block into .map-search-overlay, absolutely positioned over #cesiumContainer inside a new .map-wrap wrapper. All element IDs preserved so existing handlers in zoomWatcher and the writeQueryState() contract bind unchanged. Overlay positioning clears the Cesium toolbar column (left: 50px) and the base-layer picker dropdown wins z-stack (z-index: 1100 vs overlay's 1000).

M-1B — Sidebar open-text search input that mirrors the in-map one. (Removed in #266 — the duplicate sidebar box confused users; #sampleSearch is now the only search input. Kept for historical context:) A second input #sampleSearchSidebar lived at the top of .side-panel. Two-way input-event mirror keeps both inputs in lock-step as a single logical query term:

  • Typing in #sampleSearchSidebar propagates to #sampleSearch.
  • Typing in #sampleSearch propagates to #sampleSearchSidebar.
  • Mirror handlers guard against feedback loops by comparing values before assignment; programmatic .value = does not fire input, so the comparison is sufficient (no debounce flag).
  • applyQueryToSearch() hydrates both inputs from the ?search= URL param.
  • writeQueryState() still reads from #sampleSearch only — mirror parity makes the choice arbitrary.

Sidebar Enter = world scope (Option B). Enter on #sampleSearchSidebar always calls doSearch('world'), regardless of the in-map two-button scope choice. Rationale: typed-text-from-sidebar implies "find anywhere"; the in-map buttons remain the explicit way to constrain to current viewport. Both Enter handlers gate on !e.isComposing && e.keyCode !== 229 so IMEs that emit Enter to commit a candidate don't submit on the pre-commit value.

M-2 — Display-only color legend at bottom-center of the map. .map-color-legend is a static aria-hidden="true" swatch row with pointer-events: none. It mirrors the four-source palette from assets/js/source-palette.js and never affects filter state — the functional toggles remain in #sourceFilter in the sidebar. Sits above Cesium's bottom-left credits via bottom: 30px.

M-5 — Permanent samples table; Globe/Table toggle removed. The biggest delta to this contract:

  • #globeViewBtn, #tableViewBtn, body.table-view-active, isTableViewActive(), and the ?view= URL param are all gone.
  • The samples table is always visible below the globe. Map height shrinks to clamp(400px, 50vh, 540px) (was 500/65vh/680) so the table fits below without a full-page-height feel.
  • tableView cell initializes by calling refreshTable() unconditionally on boot.
  • writeQueryState() does params.delete('view') to canonicalize legacy bookmarked ?view=table&... URLs. Caveat: only the next writeQueryState() call strips the param — hash-only writes via buildHash(viewer) (camera moves, sample/cluster click, Share) preserve location.search as-is, so ?view= lingers until the user touches a filter, the search box, or anything else that flows through writeQueryState().
  • Table-row click = sample-mode globe click. Clicking a row in .samples-table tbody tr[data-pid] reuses the same async-selection ceremony as the search-row click handler (and the on-globe sample-point click), with one table-specific addition: the direct hash write. Preconditions before any work: bail if e.target.tagName === 'A' (let inline source-link clicks through), bail if the row has no resolvable sample / no lat-lng, bail if typeof viewer === 'undefined'. Once preconditions pass:
    1. const isStale = freshSelectionToken(viewer) — bump BEFORE any await.
    2. viewer._globeState.selectedPid set; selectedH3 cleared.
    3. updateSampleCard({...}) populates the sidebar.
    4. viewer.camera.flyTo({...}) at altitude 50000.
    5. Table-specific: history.replaceState(null, '', buildHash(viewer)) writes the #pid hash directly. The search-row and globe-point paths do not need this — they rely on zoomWatcher's camera listener to fold selection into the hash. The table-row path can fire at very-early-boot before zoomWatcher is wired and while _suppressHashWrite is still true, so it writes the hash itself.
    6. Repaint .selected: remove from any prior tr.selected, add to the clicked row — synchronously, before the async detail query.
    7. Lazy-load description from wide_url with the pid SQL-escaped via pid.replace(/'/g, "''"); gated on if (isStale()) return before any DOM/state mutation. The error branch also stale-checks. A rowsByPid: Map cached at refreshTable() time gives O(1) per-PID lookup on click.
  • Asymmetric selection sync. Clicking a table row updates the globe + sidebar + URL. Clicking a globe point or a search-result row does NOT live-update the table's .selected class — the table only repaints .selected on the next renderTable(). Treated as acceptable scope for v1; bidirectional highlight is a follow-up.

State surfaces added by mockup-v1. See the table additions in §1 (URL params: search_scope; legacy view struck), §3 (DOM-as-state: tr.selected, data-pid on table rows; body.table-view-active struck), and §5 (OJS cell graph: tableView no longer writes view to URL; tableView does write #pid to hash via buildHash).

Table v2 addendum: server-side pagination (#218, 2026-05)

Follow-up to the mockup-v1 PR. The samples table no longer fetches up to 25,000-100,000 rows up-front and paginates client-side. Each page is now its own DuckDB LIMIT TABLE_PAGE_SIZE OFFSET page*size query, plus one COUNT(*) query per filter change. Removes the #maxSamples input + getTableMaxSamples() / clampTableMaxSamples() helpers entirely.

Determinism. ORDER BY pid plus WHERE pid IS NOT NULL on both the page query and the COUNT(*) query makes "Page N is the same N rows" actually true, and keeps the count consistent with what's pageable. Defensive null filter even though pid is the canonical identifier and should never be null — ORDER BY a column that contains nulls is only deterministic by accident on a read-only parquet snapshot, and an unfiltered count could over-enable pagination past the last non-null page.

Stale-while-loading. When filters change or the user pages, the existing rendered rows stay visible (dimmed to 60% opacity via #tableContainer.is-loading .samples-table) while the new page+count queries run in the DuckDB Web Worker. A CSS-only spinner appears in #tableMeta. #tableContainer[aria-busy="true"] exposes the state to screen readers. The pager-info text is cleared during load to avoid showing stale "Page 3 of 12 (200-300 of 1,200)" against an incoming filter set. prefers-reduced-motion is honored.

Race protection. A pageGen integer is bumped on every refresh. Inner queries (loadCount, loadPage) compare gen === pageGen BEFORE mutating pageRows, pageRowsByPid, totalRows, or currentPage. refreshAll / refreshPage re-check the same gen before clearing the loading state, so a faster newer load can win the visible UI even if an older load resolves last.

Error handling. loadCount and loadPage both return true/false to the orchestrator. Three distinct error surfaces:

  • Page load failed: meta shows the error, lastPageFailed flag flips on, and renderTable() swaps the table body for an explicit "Page query failed. Adjust filters or click Previous/Next to retry." sentinel row (rather than leaving the old, now-inert rows visible with a cleared pageRowsByPid). Pager text is cleared.
  • Count failed but page succeeded: rows render, but totalRows stays null. Pager text shows "Page N" without the total. The Next button is disabled while totalRows == null (so a user can't click it into a no-op handler).
  • Both failed: generic error meta; sentinel table state.

This replaces the round-1 codex finding where the error meta was being overwritten by the success summary, and the round-2 finding where a failed page left old DOM visible but pageRowsByPid empty.

Click handler unchanged. Table-row click uses pageRowsByPid: Map (renamed from rowsByPid) which is now scoped to the current page only — sufficient since only the visible page has clickable rows.

Viewport coupling (PR #219, added shortly after table v2): both the page and count queries now include viewerBboxSQL('latitude', 'longitude', 0.3) which expands to AND latitude BETWEEN <s> AND <n> AND <lng-clause> (with dateline-crossing handling, including the post-padding wrap case where a non-wrapping rect spills past ±180 after the 30% expansion).

Antimeridian wrap bug — FIXED in #220 (was scoped out of #219). loadViewportSamples() in zoomWatcher (the point-mode sample loader) previously had its own padding logic that did not split the longitude predicate when the padded rectangle wraps the antimeridian — bounds.east - bounds.west is meaningless for a wrapping viewport, and the resulting single BETWEEN clause returned zero matches. #219's table queries route through viewerBboxSQL() and split the wrapped predicate correctly, so the table was already fine at the dateline; the bug remained only in the point-mode loader + its count (phase-msg "Samples in View", computed off the same WHERE). #220 fixes it by routing loadViewportSamples() through the SAME viewerBboxSQL('latitude', 'longitude', VIEWPORT_PAD_FACTOR) helper, so point mode, the "Samples in View" count, and the table now all use the identical wrap-aware, post-padding-normalized bbox. (Originally scoped out of #219 because the user's primary complaint — table=6M vs phase-msg=153 at Crete — was unrelated to the dateline.) The shared VIEWPORT_PAD_FACTOR (0.3) is applied by the table query, the point-mode loader, and the cluster-mode countInViewport() call sites, so all three surfaces use the same padded bbox for their "in view" counts in the non-dateline, non-facet-filtered case. Caveats called out below: the antimeridian padding bug is now fixed (#220 — point mode shares viewerBboxSQL), cluster counts are H3-cell-granularity approximations, and cluster H3 loads don't apply the material/context/object-type facet filters — so under those conditions the counts can still diverge independent of this PR. Issue #221 was three sources of divergence:

  1. Cache reuse (point mode). The point-mode loader cached a padded bbox + cachedTotalCount and short-circuited cache-hit pans (re-using the prior count) while the table re-queried the current padded bbox each time. Removed: both surfaces always re-fetch.
  2. Different trigger events (point mode). The table refreshed on every viewer.camera.moveEnd, but point-mode samples only refreshed through the debounced camera.changed handler (percentageChanged = 0.1), so small sub-10% pans refreshed the table without touching point-mode. Fixed: point-mode now also re-fetches on moveEnd (the camera.changed "already in point mode" branch is now a no-op).
  3. Unpadded cluster bbox (cluster mode, round 2). The cluster "Samples in View" stat called countInViewport(getViewportBounds()) with the raw view rectangle, while the table queried the 30%-padded bbox — so the table consistently overcounted by the padding ring (e.g. at alt=302 km over Egypt: table=34,983 vs phase-msg=34,879). Fixed: the three countInViewport call sites now pass paddedViewportBounds(VIEWPORT_PAD_FACTOR), sharing the same wrap-normalization helper that viewerBboxSQL uses.

exitPointMode() bumps requestId so any sample query still in flight when the user zooms out is invalidated before it can repaint stat boxes / phase-msg with stale point-mode numbers.

Residual cluster-mode divergence: even with matching bboxes, countInViewport filters H3 cells by their center and credits each cell's full sample_count, while the table filters individual sample lat/lng — so at low H3 resolutions (res 4, alt > 3 Mm) a small granularity-based gap remains. Inherent to cluster aggregation; not addressed here.

Note: this is viewport-count parity only. The pre-existing antimeridian wrap bug in point-mode's own bbox padding (latitude/longitude BETWEEN ... doesn't split the wrapped longitude predicate) still causes the phase-msg to undercount near the dateline. That's tracked separately (issue #220).

doSearch('area') calls the same helper without a padFactor (= undefined, no padding), since search has always been documented as "samples within the current map view."

The table re-fires on viewer.camera.moveEnd, so panning/zooming the globe re-scopes the table. Null-bbox handling: if the camera can't produce a view rectangle (off-globe; rare), viewerBboxSQL() returns null. The table treats this as a status state ("No globe area in view; pan or zoom the globe to see samples.") rather than falling back to a no-bbox query — that's the bug shape PR #219 was filed against. Area search keeps its existing world fallback.

Boot ordering: the viewer cell's once() postRender handler now sets viewer._initialCameraApplied = true after setView. The table defers its first refresh until either (a) the flag is true at cell-run time, or (b) the camera.moveEnd listener fires (which it will, once setView lands). This avoids the brief flash of world-view rows at boot when the URL hash specifies a deep-zoom camera.


7. Facet-count contract

The cross-filter rule (codified by Codex in #158, restated here):

dimension counts respect it? rationale
other facet selections YES counts answer "if I add this value, how many samples would match all OTHER active filters plus this one"; that's the drill-out signal users want
viewport (camera bounds) NO counts are global. Viewport-scoped counts would couple facet UI to camera state, contradict the "facets describe the dataset" reading, and require re-querying on every camera change
?search= text query NO option (C); search renders a side panel + result-pin overlay, but does not alter facet counts
view mode (globe vs table) moot — mockup-v1 (#200) removed the Globe/Table toggle; both surfaces are permanent

Exposed via applyFacetCounts(facetKey, countsMap) (:551-567):

  • countsMap = null ⇒ render the baseline counts from viewer._baselineCounts.
  • countsMap = Map<value, count> ⇒ render the cross-filtered counts; missing keys render as 0 (and pick up the .zero class).

The "single active dim" fast path (:1548-1584) reads from a pre-aggregated cross_filter_url parquet for instant response when exactly one facet value is selected; the general path (:1586-1604) issues four parallel GROUP BY queries against facets_url, one per dimension, each excluding its own active values from the WHERE.


8. Out of scope

  • Refactoring into modules (urlState, globeView, facetCounts, tableView, sampleCard, search). Once this contract is stable, the modular rewrite is a follow-up — not a prerequisite.
  • Pure-OJS-reactive vs pure-imperative migration. We commit to imperative + URL-canonical here; the mechanical rewrite is separate.
  • UX/copy items 1, 2, 3, 5, 6 from #163. They unblock against this contract but don't belong in it.
  • Backend changes to the search query (FTS index, server-side service). See the parallel Codex investigation.

9. Acceptance signals

  • ✅ Inventory tables populated from current explorer.qmd.
  • ✅ Search-semantics decision recorded with rationale (option C, side-panel + result-pin overlay).
  • ✅ Facet-count contract restated.
  • ⏭ #163 items can now be re-scoped against this doc:
    • #163 item 4 (zero search results + populated map looks broken) is resolved by option C, not by side-panel copy alone. Implementation must verify all four result-set shape cases (zero, one, local-many, global-many) per the table in §6.
    • #163 item 6 (page in URL) gets a clear contract slot to plug into (a page query param participating in the same writeQueryState/ hydration cycle as view).
    • The remaining items (1, 2, 3, 5, 7) are state-preserving UI fixes that don't perturb this contract.