Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 140 additions & 22 deletions EXPLORER_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,30 @@ 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 is being written against).
`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](https://github.com/isamplesorg/isamplesorg.github.io/issues/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` (`:937`) | `writeQueryState()` (`:445-446`) called from `doSearch()` (`:1789`) and from globe/table toggle | trim only; min 2 chars enforced at search time, not in URL | written even on no-result searches |
| `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` | `body.classList.contains('table-view-active')` | omitted (= globe) | `table` only; absent ⇒ globe | `tableView` cell reads `params.get('view')` (`:1239-1240`) and calls `setView(...)` with `updateUrl=false` | `writeQueryState()` from `setView(mode, true)` via globe/table button clicks (`:1217-1218`, `:1210`) | only `table` is honored (`:1240`) | `writeQueryState()` reads body class, not `viewer.*` |
| `page` | inner closure `let page = 0` in `tableView` (`:1080`) | not in URL | — | — | resets to 0 on `refreshTable()` (`:1163`); ±1 on prev/next (`:1219-1220`) | clamped to `[0, totalPages-1]` (`:1112`) | **#163 item 6** — table page is intentionally not URL state today; if/when added, must coexist with the cross-filter contract below |
| ~~`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 |

### Quarto site-search collision
Expand Down Expand Up @@ -80,17 +89,21 @@ predates the store-on-`viewer` pattern and isn't worth migrating.

| location | role | written by | read by | notes |
|----------|------|------------|---------|-------|
| `document.body.classList['table-view-active']` | view marker (table vs globe) | `setView()` in `tableView` (`:1198`) | `writeQueryState()` (`:462`); `isTableViewActive()` (`:755`) | URL `view` param is **derived** from this class, not the other way around |
| `data-facet`, `data-value` on `.facet-row` and `.facet-count` | facet selectors for in-place count mutation | rendered in `renderFilter` (`:1053`) and the static source legend (`:270-273`) | `applyFacetCounts()` (`:553-565`) | 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()` (`:1822`) and `updateSamples()` not currently using data-* (only the search list does) | search-row click handler (`:1832-1845`) | the nearby-samples list does not have data-* today; click-to-fly only works from the search list |
| ~~`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 `rowsByPid: Map` cached at `refreshTable()` time; not the DOM |
| `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 refresh) |
| `.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` + `#maxSamples`)
are the **source of truth** for `getActiveSources()`, `getCheckedValues()`,
`getTableMaxSamples()`, and the search input. SQL builders read these
directly each call.
DOM input elements (the four facet checkbox bodies + `#sampleSearch` +
`#sampleSearchSidebar` + `#maxSamples`) are the **source of truth** for
`getActiveSources()`, `getCheckedValues()`, `getTableMaxSamples()`, 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).

---

Expand Down Expand Up @@ -125,8 +138,9 @@ This invariant exists because there's no central "selection store" — selection
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;
tableView ⇒ view hydration)."
DAG (phase1 ⇒ source/search hydration; facetFilters ⇒ facet hydration).
Mockup-v1 removed the `tableView ⇒ view` hydration step along with the
`?view=` URL param."

---

Expand All @@ -142,7 +156,7 @@ 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; reads view from URL once]
├── 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]
```
Expand All @@ -155,16 +169,17 @@ viewer [creates Cesium viewer; reads readHash() once]
| `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` | `#globe-layout`, `#tableContainer`, `#tableControls`, `#samplesTable`, `body.classList` | globe/table buttons; prev/next; max input; **change** on all four facet bodies | `replaceState` via `writeQueryState` (`view` flip) |
| `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`; `window` `hashchange`; share button; search button; search input keydown | `pushState` and `replaceState` via `buildHash` (camera, mode flip, sample fly); `replaceState` via `writeQueryState` (filter changes, search submit, share) |
| `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) |
| `perfPanel` | `:1910` | `phase1` | `#perfPanel` floating div | close button | — |

Note that **two cells register `change` listeners on the four facet container
elements**: `tableView` (`:1233-1236`) marks the table dirty and refreshes if
visible, and `zoomWatcher` (`:1617`, `:1649-1651`) 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.
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.

---

Expand Down Expand Up @@ -336,6 +351,109 @@ 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](https://github.com/isamplesorg/isamplesorg.github.io/issues/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 `#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`).

---

## 7. Facet-count contract
Expand All @@ -347,7 +465,7 @@ The cross-filter rule (codified by Codex in #158, restated here):
| 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) | **NO** | the same dataset underlies both; facet counts should not flip when the user toggles view |
| ~~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`):

Expand Down
Loading
Loading