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.
| field | owner | default | URL repr | hydration site | write-back trigger | validation | notes |
|---|---|---|---|---|---|---|---|
search |
DOM #sampleSearch value (mirrored to #sampleSearchSidebar) |
omitted | raw string | applyQueryToSearch() at start of phase1 (hydrates both inputs) |
writeQueryState() called from doSearch() |
trim only; min 2 chars enforced at search time, not in URL | written even on no-result searches |
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' |
sidebar #sampleSearchSidebar Enter always submits world, never area — see §6 mockup-v1 addendum |
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 |
?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.
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 |
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.
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).
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 +
#sampleSearchSidebar) are the source of truth for
getActiveSources(), getCheckedValues(), and the search input. SQL
builders read #sampleSearch directly each call. #sampleSearchSidebar
is kept in lock-step with #sampleSearch via a two-way input-event
mirror (see §6 mockup-v1 addendum). 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.
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 |
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.
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."
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.
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.
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.
- (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.
What stays the same as (B). The backend / data-source contract is unchanged from option (B):
- The H3 summary parquets carry only
dominant_source,sample_count, and centroid coords. Cluster mode is not text-aware. - The cross-filter facet-count contract landed in #158 still excludes search from the count predicate.
viewer._globeStatedoes 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()+pidhash 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).
- User clears the search input (or
- 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
:773needs av.searchResultPoints = new Cesium.PointPrimitiveCollection()alongside the existingh3PointsandsamplePoints. - One additional
data-piddata path: the search-result pin click handler duplicates the sample-mode click handler. Acceptable; both paths are small.
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) |
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 |
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 JOINtosamples_map_litefor 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 JOINsamples_map_liteinside the candidate selection, viewportBETWEENpredicate applied beforeORDER 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-areapotteryquery 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; defaultworld, omitted from URL when default. Hydrated on boot; written bypersistSearchScope(). - 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 JOINdrops samples that have facets but nosamples_map_literow. World mode keeps them (viaLEFT 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.
A second input #sampleSearchSidebar lives 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
#sampleSearchSidebarpropagates to#sampleSearch. - Typing in
#sampleSearchpropagates to#sampleSearchSidebar. - Mirror handlers guard against feedback loops by comparing values
before assignment; programmatic
.value =does not fireinput, so the comparison is sufficient (no debounce flag). applyQueryToSearch()hydrates both inputs from the?search=URL param.writeQueryState()still reads from#sampleSearchonly — 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. tableViewcell initializes by callingrefreshTable()unconditionally on boot.writeQueryState()doesparams.delete('view')to canonicalize legacy bookmarked?view=table&...URLs. Caveat: only the nextwriteQueryState()call strips the param — hash-only writes viabuildHash(viewer)(camera moves, sample/cluster click, Share) preservelocation.searchas-is, so?view=lingers until the user touches a filter, the search box, or anything else that flows throughwriteQueryState().- 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 ife.target.tagName === 'A'(let inline source-link clicks through), bail if the row has no resolvable sample / no lat-lng, bail iftypeof viewer === 'undefined'. Once preconditions pass:const isStale = freshSelectionToken(viewer)— bump BEFORE any await.viewer._globeState.selectedPidset;selectedH3cleared.updateSampleCard({...})populates the sidebar.viewer.camera.flyTo({...})at altitude50000.- Table-specific:
history.replaceState(null, '', buildHash(viewer))writes the#pidhash directly. The search-row and globe-point paths do not need this — they rely onzoomWatcher's camera listener to fold selection into the hash. The table-row path can fire at very-early-boot beforezoomWatcheris wired and while_suppressHashWriteis stilltrue, so it writes the hash itself. - Repaint
.selected: remove from any priortr.selected, add to the clicked row — synchronously, before the async detail query. - Lazy-load
descriptionfromwide_urlwith the pid SQL-escaped viapid.replace(/'/g, "''"); gated onif (isStale()) returnbefore any DOM/state mutation. The error branch also stale-checks. ArowsByPid: Mapcached atrefreshTable()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
.selectedclass — the table only repaints.selectedon the nextrenderTable(). 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,
lastPageFailedflag flips on, andrenderTable()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 clearedpageRowsByPid). Pager text is cleared. - Count failed but page succeeded: rows render, but
totalRowsstaysnull. Pager text shows "Page N" without the total. The Next button is disabled whiletotalRows == 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).
Known pre-existing wrap bug NOT fixed in this PR.
loadViewportSamples() in zoomWatcher (the point-mode sample
loader) has its own padding logic that does 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 can
return zero matches. This PR's table queries now route through
viewerBboxSQL() and split the wrapped predicate correctly, so the
table is fine at the dateline; the bug is now only in the
point-mode count (phase-msg "Samples in View"). At wrapping
viewports the two surfaces will therefore diverge: table shows the
correct row set, phase-msg can read zero or undercount. Scoped out
as a follow-up because the user's primary complaint (table=6M vs
phase-msg=153 at Crete) is unrelated to the dateline. Right fix is
to share the viewerBboxSQL-style normalization with point-mode.
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: point mode still has the antimeridian padding bug
(#220), 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:
- Cache reuse (point mode). The point-mode loader cached a
padded bbox +
cachedTotalCountand 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. - Different trigger events (point mode). The table refreshed
on every
viewer.camera.moveEnd, but point-mode samples only refreshed through the debouncedcamera.changedhandler (percentageChanged = 0.1), so small sub-10% pans refreshed the table without touching point-mode. Fixed: point-mode now also re-fetches onmoveEnd(thecamera.changed"already in point mode" branch is now a no-op). - 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 threecountInViewportcall sites now passpaddedViewportBounds(VIEWPORT_PAD_FACTOR), sharing the same wrap-normalization helper thatviewerBboxSQLuses.
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.
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 |
| 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 fromviewer._baselineCounts.countsMap = Map<value, count>⇒ render the cross-filtered counts; missing keys render as0(and pick up the.zeroclass).
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.
- 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.
- ✅ 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
pagequery param participating in the samewriteQueryState/ hydration cycle asview). - The remaining items (1, 2, 3, 5, 7) are state-preserving UI fixes that don't perturb this contract.