diff --git a/PLAN_PIN_OVERLAY_2026-07-17.md b/PLAN_PIN_OVERLAY_2026-07-17.md new file mode 100644 index 0000000..53b7508 --- /dev/null +++ b/PLAN_PIN_OVERLAY_2026-07-17.md @@ -0,0 +1,102 @@ +# Pin-overlay implementation plan (option C, #164/#165 → #172 §7 track) + +*2026-07-17, RY green-light "start now". Strategy: same playbook that shipped FTS +this morning — additive increment first, behavior change behind a flag, staging +verify, flip on confirmation.* + +## Context deltas since the contract was written (May) + +1. **Search is now a global filter** (#234 A1): `search_pids` semi-joins constrain + table, points, facet counts. The contract's "facet counts exclude search" clause + describes the *target* state, not today's. Users have 6+ weeks of habit with + filter-everything behavior. +2. **Area/world scope already exists** (#178 landed): `?search_scope`, two-button UI, + viewport-scoped SQL. Contract's "Light-path addendum" is DONE — do not rebuild. +3. **FTS is the default backend** (#335, today). Contract explicitly says (C) is + backend-orthogonal — confirmed still true (both producers emit `search_pids`). + +## Increments + +### Inc 1 — additive pin overlay (no behavior removed) ← TODAY +After any search completes, render matching samples as a pin overlay per the +contract's non-negotiable rules: +- `viewer.searchResultPoints` = new `PointPrimitiveCollection`, added to + `scene.primitives` AFTER `h3Points`/`samplePoints` (z-order rule) +- ≤ 50 pins (displayed result set), hollow-ring styling, `SOURCE_COLORS` palette, + pixel size 8 +- hover label + click → `updateSampleCard()` + pid hash write (dispatch on + `meta.type === 'searchResult'`) +- lifecycle: populate on results; clear per SNAPSHOT SEMANTICS below +- camera (amended per Codex round-1 P1.5): Inc 1 ships only the cheap half — + fly to the first *located* result @ 200000 (explicit null checks so 0° coords + are valid), auto-fly suppressed in area scope (existing behavior). **Fit-to- + bounds (the 30°×30° extent rule) is DEFERRED to Inc 2** — it is the only + camera work not in Inc 1. +- global refiltering UNCHANGED in Inc 1 — pins are purely additive +- acceptance: the contract's four shape cases (zero / one / local-many / + global-many). Inc 1 asserts pin *identity/count/replacement/clear* for all + four; the **local-many fit-to-bounds ZOOM assertion is deferred to Inc 2** + with the fit-to-bounds implementation (Inc 1 flies to the first located + result in every non-area case instead) +- **lifecycle (amended, Codex round-1 P1.2 — SNAPSHOT SEMANTICS):** the pins are + a snapshot of the *rendered results list* and reconcile with the LIST + lifecycle, not with filter state. Cleared wherever the list is torn down + (new/empty submit, described-by takeover, globe sample/cluster click). They + deliberately PERSIST when the list persists stale (a source/facet toggle while + a search is active updates #300 filtered surfaces but not the frozen list) — + that staleness is pre-existing list behavior, documented, not a new pin bug. + Known pre-existing incoherence left for a follow-up issue: a draft-input clear + followed by a source/facet change makes `writeQueryState()` drop `search=` + from the URL while the runtime filter (and thus list + pins) survive. +- **pin-count semantics (amended per review):** one pin per *located* displayed + result (world scope legitimately returns coordinate-less rows — never coerce to + (0,0)); panel reports displayed vs pinned counts when they differ; camera rules + use the first *located* result +- **click parity (amended, Codex round-1 P1.3):** the sample-click ceremony + (freshness token, card, selection/hash, detail hydration) is the shared + `selectSearchResult()` helper with a preserve-results-list option; pin clicks + use it — no shallow duplicate branch. Pin clicks get the FULL in-map card + hydration (material / feature / specimen / thumbnail) by reusing + `openInMapCardForSample()`, not a description-only fetch; the helper takes the + caller's freshness token to avoid a double-bump +- **styling (amended):** carry `scaleByDistance` explicitly; use the bounded + `POINT_DEPTH_TEST_DISTANCE` for `disableDepthTestDistance` (Codex round-1 + P1.1 — `Infinity` bleeds pins through the far side of the globe) + +### Inc 2 — `?searchui=pin` mode (the latency win) ← behind flag +**Seam corrected per Codex plan review (P0):** skipping `applySearchFilterChange()` +is NOT sufficient — `window.__searchFilter.active` is consulted independently by +searchFilterSQL() (table/export/points/heatmap/facets), computeTargetMode(), +table messaging, heatmap cache keys, and camera moveEnd refreshes. The seam is a +single semantic predicate **`searchFiltersSurfaces()`** (false only for +`kind === 'text' && searchui === 'pin'`; concept/described-by filtering stays +global), used consistently by every consumer. Skipping the direct call then +becomes a latency optimization, not the correctness mechanism. +- expected effect: search response ≈ filter-build time (~1-5s cold) instead of + +10-20s of surface refiltering — this is what makes #172 §7 winnable +- table/facets/clusters keep their pre-search state (facet counts stay + search-independent, per original #158 contract) +- clear messaging in panel: "N matches pinned on globe" replaces "N of N in view" +- default stays current behavior until team confirms (Andrea/Eric habit risk); + the eventual flip is a real change (tests/docs/rollback/telemetry), not 1 line — + `searchui=pin|filter` recorded as a temporary rollout flag (deliberate, + documented deviation from the "URL params unchanged" clause) +- URL: `searchui=pin` round-trips like `fts` param (state contract: no new hash state) + +### Inc 3 — NOT in scope +Scope buttons (done), mockup-v1 sidebar mirror (#200 — parked per closure triage). + +## Verification +- 4 acceptance cases on staging (fork Pages dispatch from branch, as this morning) +- e2e spec additions: pins count == min(50, results); pins cleared on clear; + `?searchui=pin` skips refilter (assert facet counts unchanged after search) +- Codex loop: plan review (round 1 DONE 7/17 — P0 seam corrected, this doc amended) + → implement → per-increment review rounds → LGTM gate +- honest sizing (Codex): Inc 1 ≈ 1–2 days · Inc 2 ≈ 2–4 days · staging ≈ 1 day + +## Files +- `explorer.qmd`: viewer cell (~:773 area) for collection; `doSearch` result path + for populate/clear; mouse/click handlers for pick dispatch; search-complete path + for Inc 2 skip +- `tests/playwright/pin-overlay.spec.js` (new) + diff --git a/assets/js/explorer-utils.js b/assets/js/explorer-utils.js index fd6da63..d73e219 100644 --- a/assets/js/explorer-utils.js +++ b/assets/js/explorer-utils.js @@ -12,6 +12,16 @@ export function escapeHtml(value) { .replace(/'/g, '''); } +// Panel/list ownership check (#172 Inc 1, Codex round-4 P1). Every producer that +// writes the #samplesSection list (search / concept renders, cluster +// nearby-samples, selection-invalidation clears) captures a generation via +// `++viewer._panelGen` when it takes the list; an async producer may only write +// list + pins while it still holds the latest generation. Pure so the ownership +// invariant is unit-testable independently of the OJS runtime. +export function panelWriteAllowed(captured, current) { + return captured === current; +} + // Split a free-text query into whitespace-delimited terms (no empties). export function searchTerms(value) { return String(value || '').trim().split(/\s+/).filter(Boolean); diff --git a/explorer.qmd b/explorer.qmd index 36599ac..9050b79 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -943,6 +943,7 @@ csvParamValues = _explorerUtils.csvParamValues sourceUrl = _explorerUtils.sourceUrl readHash = _explorerUtils.readHash formatPlaceName = _explorerUtils.formatPlaceName +panelWriteAllowed = _explorerUtils.panelWriteAllowed // === Source Filter: get active sources and build SQL clause === function getActiveSources() { @@ -2137,6 +2138,16 @@ viewer = { // URL deep-link state (must be set before globalRect/once block reads it) v._globeState = { mode: 'cluster', selectedPid: null, selectedH3: null }; + // #172 Inc 1 — PANEL/LIST OWNERSHIP GENERATION (unified rule, Codex round-5): + // ANY write to the rendered panel list (#samplesSection) — clear or replace, + // synchronous or asynchronous — advances _panelGen. Async producers ALSO + // capture it at start and may only write list+pins while still current (see + // panelWriteAllowed). Synchronous clears just advance it: the bump's job is to + // invalidate any in-flight async producer so a slow search/hydration can no + // longer resurrect a panel the user has since replaced or cleared. The pid + // deep-link is card-only (never writes the list) so it never bumps this — + // that's why the boot search+pid list still renders. + v._panelGen = 0; v._initialHash = readHash(); v._suppressHashWrite = true; // cleared after zoomWatcher initializes v._suppressTimer = null; @@ -2174,6 +2185,51 @@ viewer = { v.scene.primitives.add(v.samplePoints); v.samplePoints.show = false; // hidden until point mode + // #172 pin-overlay Inc 1 (option C): search-result pin overlay. Added to + // scene.primitives AFTER h3Points and samplePoints so it renders ABOVE both + // (z-order rule). Always shown — the overlay is independent of the + // cluster/point mode toggle. + v.searchResultPoints = new Cesium.PointPrimitiveCollection(); + v.scene.primitives.add(v.searchResultPoints); + + // Test hook (#172, Codex round-1 P1.4 / P2): read-only snapshot of the pin + // overlay — [{ pid, lat, lng }] — so specs can assert identity/position and + // exact set equality, not just a count. Re-reads the collection on every + // call via the current `v`; returns [] if the collection is missing so a + // recreated viewer never leaves a stale closure raising. + if (typeof window !== 'undefined') window.__searchPins = () => { + const col = v.searchResultPoints; + if (!col) return []; + const out = []; + for (let i = 0; i < col.length; i++) { + const id = col.get(i).id || {}; + out.push({ pid: id.pid, lat: id.lat, lng: id.lng }); + } + return out; + }; + + // Test hook (#172, Codex round-3/4 P2): faithfully replay a result-pin CLICK + // by index — the FULL ceremony the on-globe LEFT_CLICK handler runs, INCLUDING + // inMapCardAt (canvas centre) so the rich floating-card hydration path runs, + // plus the shared selection helper and the pid hash push. Lets specs exercise + // pin selection and Back-navigation without Cesium canvas picking (not feasible + // in Playwright). Installed ONLY under WebDriver (Playwright sets + // navigator.webdriver) so it can't be invoked in a normal session. Returns + // false if there is no pin at that index. + if (typeof window !== 'undefined' && typeof navigator !== 'undefined' && navigator.webdriver === true) { + window.__clickSearchPin = (i = 0) => { + const col = v.searchResultPoints; + if (!col || i < 0 || i >= col.length) return false; + const meta = col.get(i).id; + const canvas = v.scene.canvas; + const inMapCardAt = { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 }; + const isStale = freshSelectionToken(v); + if (v.selectSearchResult) v.selectSearchResult(meta, { fly: true, inMapCardAt, isStale }); + writeGlobeHash(v, { replace: false, force: true }); + return true; + }; + } + // Hover tooltip — works for both clusters and samples v.pointLabel = v.entities.add({ label: { @@ -2184,6 +2240,9 @@ viewer = { disableDepthTestDistance: Number.POSITIVE_INFINITY, text: "", } }); + // Cesium's documented default label background; restored for hovers that + // carry no source (#172 P2 source-colored hover, below). + const LABEL_BG_DEFAULT = new Cesium.Color(0.165, 0.165, 0.165, 0.8); new Cesium.ScreenSpaceEventHandler(v.scene.canvas).setInputAction((movement) => { const picked = v.scene.pick(movement.endPosition); @@ -2191,13 +2250,21 @@ viewer = { v.pointLabel.position = picked.primitive.position; v.pointLabel.label.show = true; const meta = picked.id; - if (typeof meta === 'object' && meta.type === 'sample') { + // #172 P2 (Codex round-1): source-colored hover background for both + // sample points AND search-result pins, per the option (C) contract + // ("source badge color"). Cluster/plain hovers keep the default. + let hoverSource = null; + if (typeof meta === 'object' && (meta.type === 'sample' || meta.type === 'searchResult')) { v.pointLabel.label.text = `${meta.label || meta.pid}`; + hoverSource = meta.source; } else if (typeof meta === 'object' && meta.count) { v.pointLabel.label.text = `${meta.source}: ${meta.count.toLocaleString()} samples`; } else { v.pointLabel.label.text = String(meta); } + v.pointLabel.label.backgroundColor = (hoverSource && SOURCE_COLORS[hoverSource]) + ? Cesium.Color.fromCssColorString(SOURCE_COLORS[hoverSource]).withAlpha(0.85) + : LABEL_BG_DEFAULT; } else { v.pointLabel.label.show = false; } @@ -2216,7 +2283,19 @@ viewer = { const meta = picked.id; const isStale = freshSelectionToken(v); - if (typeof meta === 'object' && meta.type === 'sample') { + if (typeof meta === 'object' && meta.type === 'searchResult') { + // --- Search-result pin click (#172 pin-overlay Inc 1, option C) --- + // Runs the same selection ceremony as a sample click (card, + // selection state, flight, full in-map detail) via the shared + // helper, but PRESERVES the #samplesSection search-results list the + // user is browsing. Reuse the freshness token already bumped above + // (isStale) so the helper does not double-bump (Codex round-1 P1.3). + if (v.selectSearchResult) { + v.selectSearchResult(meta, { fly: true, inMapCardAt: { x: e.position.x, y: e.position.y }, isStale }); + } + writeGlobeHash(v, { replace: false, force: true }); + + } else if (typeof meta === 'object' && meta.type === 'sample') { // --- Individual sample click --- updateSampleCard(meta); // In-map floating card (issue #226). `e.position` is the @@ -2230,6 +2309,13 @@ viewer = { // Clear nearby list const sampEl = document.getElementById('samplesSection'); if (sampEl) sampEl.innerHTML = ''; + // #172 Inc 1 (Codex round-1 P1.2 / round-5 unified rule): this click + // tears down the rendered results list, so the pin overlay (its + // snapshot) goes with it AND _panelGen advances — a slow in-flight + // search that captured an earlier gen now bails instead of + // resurrecting the list the user just replaced with this selection. + if (v.searchResultPoints) v.searchResultPoints.removeAll(); + v._panelGen++; // Stage 2: lazy-load extended fields from wide parquet. // Extended beyond the original description-only query to @@ -2279,6 +2365,11 @@ viewer = { } else if (typeof meta === 'object' && meta.count) { // --- Cluster click --- + // #172 Inc 1 (Codex round-4 P1): claim panel ownership — this cluster + // click is a new list producer, so an older in-flight search render + // (which checks its own captured gen) bails instead of overwriting + // these nearby rows. + const myPanelGen = ++v._panelGen; updateClusterCard(meta); // Cluster click clears any prior sample selection; close // the in-map sample card so it doesn't outlive the context @@ -2290,6 +2381,10 @@ viewer = { const sampEl = document.getElementById('samplesSection'); if (sampEl) sampEl.innerHTML = '
Loading nearby samples...
'; + // #172 Inc 1 (Codex round-1 P1.2, snapshot semantics): a cluster + // click replaces the results list with nearby samples, so the + // result-pin overlay (its snapshot) is cleared too. + if (v.searchResultPoints) v.searchResultPoints.removeAll(); const delta = meta.resolution === 4 ? 2.0 : meta.resolution === 6 ? 0.5 : 0.1; try { @@ -2306,6 +2401,14 @@ viewer = { `; const samples = await db.query(nearbyQuery); if (isStale()) return; + // #172 Inc 1 (Codex round-4 P1): only write if this click still + // owns the panel. Closes the reverse race — a search that + // STARTED earlier but resolves later has a stale panel gen and + // must not overwrite these rows. (round-3 P1): write list + pins + // as ONE owned operation — no await between this guard, the + // re-clear, and updateSamples, so nothing can interpose. + if (!panelWriteAllowed(myPanelGen, v._panelGen)) return; + if (v.searchResultPoints) v.searchResultPoints.removeAll(); updateSamples(samples); } catch(err) { if (isStale()) return; @@ -4757,6 +4860,11 @@ zoomWatcher = { updateClusterCard(null); const sampEl = document.getElementById('samplesSection'); if (sampEl) sampEl.innerHTML = ''; + // #172 Inc 1 (Codex round-2/round-5): list teardown → clear + // the pin snapshot too AND advance _panelGen (unified rule) + // so a slow in-flight search can't resurrect this list. + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); + viewer._panelGen++; writeGlobeHash(viewer, { force: true }); } else { // Cluster's dominant_source still checked, but the @@ -4780,6 +4888,12 @@ zoomWatcher = { updateClusterCard(null); const sampEl = document.getElementById('samplesSection'); if (sampEl) sampEl.innerHTML = ''; + // #172 Inc 1 (Codex round-2/round-5): source filter + // invalidated the selected search result and cleared the + // list → clear its pins too AND advance _panelGen (unified + // rule) so a slow in-flight search can't resurrect it. + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); + viewer._panelGen++; writeGlobeHash(viewer, { force: true }); } } @@ -4872,6 +4986,10 @@ zoomWatcher = { updateClusterCard(null); const sampEl = document.getElementById('samplesSection'); if (sampEl) sampEl.innerHTML = ''; + // #172 Inc 1 (Codex round-2/round-5): list teardown → clear the + // pin snapshot too AND advance _panelGen (unified rule). + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); + viewer._panelGen++; writeGlobeHash(viewer, { force: true }); } else { await hydrateClusterUI(meta, isStale); @@ -5266,9 +5384,17 @@ zoomWatcher = { // because click selection is its own latest event. async function hydrateClusterUI(meta, isStale) { if (!meta) return; + // #172 Inc 1 (Codex round-4 P1): claim panel ownership so an older + // in-flight search render bails instead of overwriting these rows. + const myPanelGen = ++viewer._panelGen; updateClusterCard(meta); const sampEl = document.getElementById('samplesSection'); if (sampEl) sampEl.innerHTML = '
Loading nearby samples...
'; + // #172 Inc 1 (Codex round-2 P1, snapshot semantics): replacing the list + // with nearby samples tears down any search-results list, so the + // result-pin overlay (its snapshot) goes with it. This choke point + // covers source/facet revalidation, hashchange h3 restore, and boot. + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); const delta = meta.resolution === 4 ? 2.0 : meta.resolution === 6 ? 0.5 : 0.1; try { const samples = await db.query(` @@ -5281,6 +5407,12 @@ zoomWatcher = { LIMIT 30 `); if (isStale && isStale()) return; + // #172 Inc 1 (Codex round-4 P1): only write if this hydration still + // owns the panel — closes the reverse race with a search that + // started earlier but resolves later. (round-3 P1): list + pins as + // ONE owned operation, no await between guard, re-clear, and write. + if (!panelWriteAllowed(myPanelGen, viewer._panelGen)) return; + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); updateSamples(samples); } catch(err) { if (isStale && isStale()) return; @@ -5311,9 +5443,13 @@ zoomWatcher = { // anchors at canvas centre rather than flying. Adding a "camera far from // sample → flyTo" branch would fight the hashchange flyTo above and the // boot setView; it's a tracked follow-up, not folded in here. - async function openInMapCardForSample(meta, isStale) { + // `screenPos` (added #172 Inc 1): anchor the card at an explicit pixel — + // used by the result-pin click, which frames the card at the click point + // like the on-globe sample click. Omitted by the deep-link/hashchange + // callers, which keep the canvas-centre anchor described above. + async function openInMapCardForSample(meta, isStale, screenPos) { const canvas = viewer.scene.canvas; - showInMapCard(meta, { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 }); + showInMapCard(meta, screenPos || { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 }); try { const pidEsc = meta.pid.replace(/'/g, "''"); const detail = await db.query(` @@ -5469,6 +5605,10 @@ zoomWatcher = { updateClusterCard(null); const sampEl = document.getElementById('samplesSection'); if (sampEl) sampEl.innerHTML = ''; + // #172 Inc 1 (Codex round-2/round-5): list teardown → clear the pin + // snapshot too AND advance _panelGen (unified rule). + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); + viewer._panelGen++; } } else { viewer._globeState.selectedPid = null; @@ -5478,6 +5618,12 @@ zoomWatcher = { hideInMapCard(); const sampEl = document.getElementById('samplesSection'); if (sampEl) sampEl.innerHTML = ''; + // #172 Inc 1 (Codex round-2/round-5): the Back-after-pin-click + // no-selection branch tears down the preserved search list → clear its + // pins too AND advance _panelGen (unified rule) so a slow in-flight + // search can't resurrect the list after the user navigated away. + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); + viewer._panelGen++; } }); @@ -5941,6 +6087,17 @@ zoomWatcher = { async function clearActiveSearchFilter() { // Same clear path as an empty search submit, without entering doSearch(). _searchSeq++; + // #172 Inc 1 (Codex round-3 P1): take panel ownership so an in-flight + // selection hydration can't repopulate the list after this clear. + freshSelectionToken(viewer); + // #172 pin-overlay Inc 1 (option C / round-5 unified rule): the committed + // concept-chip clear tears down the results list, so clear the LIST and the + // pin overlay TOGETHER and advance _panelGen so an in-flight async producer + // (search / hydration) can't resurrect the list after this clear. + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); + viewer._panelGen++; + const sampElClear = document.getElementById('samplesSection'); + if (sampElClear) sampElClear.innerHTML = ''; if (searchInput) searchInput.value = ''; if (searchResults) searchResults.textContent = ''; await clearSearchFilter(); @@ -5977,6 +6134,69 @@ zoomWatcher = { if (clusterEl) clusterEl.hidden = searchIsActive(); } + // #172 pin-overlay Inc 1 (option C): shared selection ceremony for a search + // result, invoked by BOTH the side-panel row click (in doSearch) and the + // on-globe result-pin click (viewer cell, via viewer.selectSearchResult). + // Mirrors the on-globe sample-click ceremony — freshness token, card + // hydration, selection state, flight, lazy detail — but deliberately does + // NOT repaint #samplesSection (it holds the search-results list the user is + // browsing). `sample` is a normalized meta ({ pid, label, source, lat, lng, + // place_name, result_time }); coord-less rows are ignored. + // + // `inMapCardAt` (pin click only): open the floating in-map card at the click + // pixel with the SAME rich detail (material / feature / specimen / thumbnail) + // the sample click renders — via the shared openInMapCardForSample(), so the + // wide-table fetch is reused, not duplicated (Codex round-1 P1.3). + // + // `isStale` (Codex round-1 P1.3): callers that already bumped the freshness + // token (the globe click handler) pass theirs so we don't double-bump; the + // row click passes none and we bump here. + async function selectSearchResult(sample, { fly = true, inMapCardAt = null, isStale = null } = {}) { + if (!sample || sample.lat == null || sample.lng == null) return; + + // Bump the freshness token BEFORE any async work so a prior + // cluster/sample detail load can't repaint the panel after we navigate. + if (!isStale) isStale = freshSelectionToken(viewer); + + viewer._globeState.selectedPid = sample.pid; + viewer._globeState.selectedH3 = null; + updateSampleCard(sample); + if (fly) { + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(sample.lng, sample.lat, 50000), + duration: 1.5 + }); + } + + if (inMapCardAt) { + // Pin click: full in-map card hydration at the click pixel, reusing + // the shared wide-table fetch (updateSampleDetail + + // populateInMapCardDetail happen inside). + await openInMapCardForSample(sample, isStale, inMapCardAt); + return; + } + + // Row click: sidebar description only (no in-map card). Don't clear + // samplesSection — it holds the results the user is browsing. + try { + const detail = await db.query(` + SELECT description + FROM read_parquet('${wide_url}') + WHERE pid = '${sample.pid.replace(/'/g, "''")}' + LIMIT 1 + `); + if (isStale()) return; + if (detail && detail.length > 0) updateSampleDetail(detail[0]); + else updateSampleDetail({ description: '' }); + } catch(err) { + if (isStale()) return; + console.error("Search-result detail query failed:", err); + updateSampleDetail(null); + } + } + // Expose for the viewer cell's globe click handler (result-pin click). + viewer.selectSearchResult = selectSearchResult; + async function doSearch(scope) { if (scope === 'area' || scope === 'world') _searchScope = scope; const effectiveScope = _searchScope; @@ -5992,6 +6212,31 @@ zoomWatcher = { // submission — valid or not — supersedes prior in-flight work. const searchId = ++_searchSeq; + // #172 Inc 1: take PANEL OWNERSHIP two ways. + // (round-3, _selGen) bump the selection freshness token so in-flight + // sample/pid detail + cluster hydrations become stale and bail — this + // guards selection/card work. + freshSelectionToken(viewer); + // (round-4/round-5, _panelGen) this submit is a write to the panel list, + // so advance _panelGen AND capture it — one bump serves both. As a bump it + // invalidates any in-flight async producer (unified rule); as this search's + // captured generation it is re-checked (with _searchSeq) before every + // render below, so a newer list producer that starts AFTER this submit + // (e.g. a cluster click while the search is still building) makes this + // render bail instead of overwriting the cluster's rows. Does NOT break + // boot search+pid: the pid deep-link is card-only and never bumps. + const myPanelGen = ++viewer._panelGen; + + // #172 Inc 1 (Codex round-5 P1.2): a new submit tears the panel DOWN. + // Clear the old rows AND pins together AT ENTRY (under the gen just + // captured), so no stale unpinned rows linger during the async build — the + // panel shows the building status (searchResults text) until the atomic + // replacement lands. This covers BOTH the committed real search below and + // the empty/too-short branch (which just leaves the panel cleared). + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); + const sampElEntry = document.getElementById('samplesSection'); + if (sampElEntry) sampElEntry.innerHTML = ''; + const term = searchInput.value.trim(); if (!term || term.length < 2) { searchResults.textContent = 'Type at least 2 characters'; @@ -6276,6 +6521,22 @@ zoomWatcher = { wasSuperseded = true; return; } + // #172 Inc 1 (Codex round-4 P1): also bail if a newer LIST producer + // (e.g. a cluster click while this search was building) took the + // panel. Both _searchSeq (search supersession) and _panelGen (list + // ownership) must hold before we write the list/pins — there is no + // await between here and the row-render + pin-populate below, so this + // single check makes that write atomic w.r.t. panel ownership. + if (!panelWriteAllowed(myPanelGen, viewer._panelGen)) { + wasSuperseded = true; + // #172 Inc 1 (Codex round-5 P2): panel ownership and search-status + // completion are separate. A cluster took the panel, so we won't + // render — but if no NEWER search owns the status text (our + // searchId still current), settle our own "Searching…" to '' so it + // can't persist forever. If a newer search owns the text, leave it. + if (searchId === _searchSeq) searchResults.textContent = ''; + return; + } resultsCount = results.length; if (results.length === 0) { // A build failure also empties search_pids → 0 rows here, so @@ -6306,9 +6567,18 @@ zoomWatcher = { // bare result-row count visible while the precise total // resolves. See issue #232. const capReached = results.length === 50; - searchResults.textContent = capReached + // #172 pin-overlay Inc 1 (option C): one pin per LOCATED displayed + // result (world scope legitimately returns coord-less matches). When + // fewer results are located than displayed, the count line discloses + // how many landed on the globe. Cap is inherited from LIMIT 50, so + // located ≤ displayed ≤ 50. + const locatedCount = results.filter(r => r.latitude != null && r.longitude != null).length; + const pinnedNote = locatedCount < results.length + ? ` (${locatedCount} located, pinned on globe)` + : ''; + searchResults.textContent = (capReached ? `${results.length}+ results for "${term}"` - : `${results.length} results for "${term}"`; + : `${results.length} results for "${term}"`) + pinnedNote; // Show results in the samples panel const sampEl = document.getElementById('samplesSection'); @@ -6336,59 +6606,80 @@ zoomWatcher = { // click ceremony at line ~944. Without the full ceremony // a slow prior selection load could repaint the panel // after the click, leaving UI inconsistent with URL. + // #172 pin-overlay Inc 1 (option C): that ceremony now lives in + // the shared selectSearchResult() helper, used by BOTH this row + // click and the on-globe result-pin click. Row click keeps its + // prior behavior (no in-map card, no selection-hash write). const resultsByPid = new Map(results.map(s => [s.pid, s])); sampEl.querySelectorAll('.sample-row[data-lat]').forEach(row => { - row.addEventListener('click', async (e) => { + row.addEventListener('click', (e) => { if (e.target.tagName === 'A') return; // let links work const pid = row.dataset.pid; - const sample = pid ? resultsByPid.get(pid) : null; - if (!sample || sample.latitude == null || sample.longitude == null) return; - - // Bump the freshness token BEFORE any async work so - // a prior cluster/sample detail load can't repaint - // the panel after we navigate. - const isStale = freshSelectionToken(viewer); - - viewer._globeState.selectedPid = pid; - viewer._globeState.selectedH3 = null; - updateSampleCard({ - pid: sample.pid, - label: sample.label, - source: sample.source, - lat: sample.latitude, - lng: sample.longitude, - place_name: sample.place_name, - result_time: sample.result_time - }); - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(sample.longitude, sample.latitude, 50000), - duration: 1.5 - }); - - // Lazy-load full description (mirrors the globe - // click handler). Don't clear samplesSection here — - // it currently holds the search results the user - // is browsing. - try { - const detail = await db.query(` - SELECT description - FROM read_parquet('${wide_url}') - WHERE pid = '${pid.replace(/'/g, "''")}' - LIMIT 1 - `); - if (isStale()) return; - if (detail && detail.length > 0) updateSampleDetail(detail[0]); - else updateSampleDetail({ description: '' }); - } catch(err) { - if (isStale()) return; - console.error("Search-row detail query failed:", err); - updateSampleDetail(null); - } + const s = pid ? resultsByPid.get(pid) : null; + if (!s) return; + selectSearchResult({ + pid: s.pid, + label: s.label, + source: s.source, + lat: s.latitude, + lng: s.longitude, + place_name: s.place_name, + result_time: s.result_time + }, { fly: true }); }); }); } - // Fly to the first result. Skip for area-scoped searches — + // #172 pin-overlay Inc 1 (option C): render the LOCATED displayed + // results as a pin overlay on the globe. One hollow-ring pin per + // located row — skip null lat/lng (world scope legitimately returns + // coordinate-less matches; NEVER coerce to (0,0)). Old pins were + // cleared at the top of doSearch (committed-clear path); the cap is + // inherited from LIMIT 50, so pins ≤ 50 ≤ displayed results. + // + // LIFECYCLE POLICY (snapshot semantics, Codex round-1 P1.2): the + // pins are a snapshot of the rendered results list — they reconcile + // with the LIST lifecycle, not with filter state. They are cleared + // wherever that list is torn down (new/empty submit, described-by + // takeover, and the globe sample/cluster clicks that replace the + // list). They deliberately PERSIST when the list persists stale — + // e.g. a source/facet toggle while a search is active updates the + // #300 filtered surfaces but not the (frozen) results list, so the + // pins stay too. That staleness is the documented pre-existing + // behavior of the list itself, not a new pin bug. + const pinScalar = new Cesium.NearFarScalar(1e2, 8, 2e5, 3); // mirror samplePoints + for (const s of results) { + if (s.latitude == null || s.longitude == null) continue; + const c = SOURCE_COLORS[s.source] || '#666'; + viewer.searchResultPoints.add({ + id: { + type: 'searchResult', + pid: s.pid, + label: s.label, + source: s.source, + lat: s.latitude, + lng: s.longitude, + place_name: s.place_name, + result_time: s.result_time + }, + position: Cesium.Cartesian3.fromDegrees(s.longitude, s.latitude, 0), + pixelSize: 8, + // Hollow ring: faint fill + bright source-colored outline so + // the overlay reads as distinct from the filled cluster and + // sample dots while still signalling which source matched. + color: Cesium.Color.fromCssColorString(c).withAlpha(0.15), + outlineColor: Cesium.Color.fromCssColorString(c), + outlineWidth: 2, + scaleByDistance: pinScalar, + // #185 (Codex round-1 P1.1): bounded depth-test distance — + // Number.POSITIVE_INFINITY bleeds pins through the far side + // of the globe; POINT_DEPTH_TEST_DISTANCE matches the h3/ + // sample layers and prevents that back-hemisphere bleed. + disableDepthTestDistance: POINT_DEPTH_TEST_DISTANCE, + }); + } + + // Fly to the first LOCATED result. Skip for area-scoped searches — // the user is already at the area they care about; flying // would zoom in and disorient. // @@ -6397,12 +6688,21 @@ zoomWatcher = { // doesn't carry stale selection state across the flight // (issue #207 item 8). User clicks on a specific row to // establish a new selection. - if (effectiveScope === 'world' && results[0].latitude && results[0].longitude) { + // + // #172 Inc 1 (Codex round-1 P1.5): scan for the first row with + // real coordinates using EXPLICIT null checks (0° lat/lng is + // valid). results[0] can be coord-less in world scope (LEFT JOIN + // to lite), so the old truthiness test on results[0] both skipped + // the flight for a coord-less top row and would have mis-treated + // 0,0. Fit-to-bounds (the 30°×30° camera rule) remains deferred to + // Inc 2 per PLAN_PIN_OVERLAY_2026-07-17.md. + const firstLocated = results.find(r => r.latitude != null && r.longitude != null); + if (effectiveScope === 'world' && firstLocated) { const cancelFinalFacetRefresh = refreshFacetCountsAfterSearchFlight(searchId); viewer._globeState.selectedPid = null; viewer._globeState.selectedH3 = null; viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(results[0].longitude, results[0].latitude, 200000), + destination: Cesium.Cartesian3.fromDegrees(firstLocated.longitude, firstLocated.latitude, 200000), duration: 1.5, cancel: cancelFinalFacetRefresh, }); @@ -6467,9 +6767,11 @@ zoomWatcher = { // When `total === results.length`, the cap happened to // catch exactly the full set — drop the "of N" to // avoid a redundant "50 of 50 results" reading. - searchResults.textContent = total > results.length + // #172: preserve the located/pinned disclosure across the + // real-count follow-up (pinnedNote is captured above). + searchResults.textContent = (total > results.length ? `${results.length} of ${total.toLocaleString()} results for "${term}"` - : `${results.length} results for "${term}"`; + : `${results.length} results for "${term}"`) + pinnedNote; } catch(err) { countMs = performance.now() - countStart; if (searchId !== _searchSeq) { @@ -6499,9 +6801,14 @@ zoomWatcher = { // Honesty fix (#247): keep the side panel consistent with the // table meta's "→ panel" pointer on failure too, instead of // leaving stale results under a pointer that now lies. - const sampElErr = document.getElementById('samplesSection'); - if (sampElErr) sampElErr.innerHTML = searchHeadingHTML('') - + '
Search failed — please try again.
'; + // #172 Inc 1 (Codex round-4 P1): but only if we still own the + // panel — a newer list producer must not have its rows replaced + // by this (older) search's failure message. + if (panelWriteAllowed(myPanelGen, viewer._panelGen)) { + const sampElErr = document.getElementById('samplesSection'); + if (sampElErr) sampElErr.innerHTML = searchHeadingHTML('') + + '
Search failed — please try again.
'; + } } } finally { performance.mark(markEnd); @@ -6609,6 +6916,23 @@ zoomWatcher = { // filter can't clobber each other's side-panel render mid-flight. const searchId = ++_searchSeq; + // #172 Inc 1: take PANEL OWNERSHIP — same as doSearch. (round-3, _selGen) + // bump the selection token so in-flight selection hydrations bail; (round-4/ + // round-5, _panelGen) advance + capture a LIST generation — as a bump it + // invalidates in-flight async producers (unified rule); as this concept's + // gen it's re-checked before every render, so a cluster click that starts + // AFTER this commit wins the list. The render also stays guarded by + // searchId/_searchSeq. + freshSelectionToken(viewer); + const myPanelGen = ++viewer._panelGen; + + // #172 Inc 1 (Codex round-5 P1.2): committing a concept tears the panel + // DOWN — clear the old rows AND the text-search pins together at entry + // (the panel shows "Filtering by concept…" until the concept list lands). + if (viewer.searchResultPoints) viewer.searchResultPoints.removeAll(); + const sampElEntry = document.getElementById('samplesSection'); + if (sampElEntry) sampElEntry.innerHTML = ''; + // Mutual exclusivity: a committed concept filter OWNS search_pids, so // clear the free-text box (and its sidebar mirror) before building. if (searchInput) searchInput.value = ''; @@ -6657,8 +6981,13 @@ zoomWatcher = { const headingHTML = (suffix) => `

Samples described by: ${safeLabel}${suffix}

`; + // #172 Inc 1 (Codex round-4 P1): a newer list producer (cluster click) + // may own the panel now — gate the failed/empty LIST renders on it (the + // URL write above stays _searchSeq's concern). + const conceptOwnsPanel = () => panelWriteAllowed(myPanelGen, viewer._panelGen); if (buildFailed) { if (searchResults) searchResults.textContent = `Concept filter failed for "${label}". Please try again.`; + if (!conceptOwnsPanel()) return; const sElF = document.getElementById('samplesSection'); if (sElF) sElF.innerHTML = headingHTML('') + '
Filter failed to build — please try again.
'; @@ -6666,6 +6995,7 @@ zoomWatcher = { } if (total === 0) { if (searchResults) searchResults.textContent = `No samples described by "${label}"`; + if (!conceptOwnsPanel()) return; const sEl0 = document.getElementById('samplesSection'); if (sEl0) sEl0.innerHTML = headingHTML(' (0)') + '
No samples matched this concept.
'; @@ -6700,6 +7030,9 @@ zoomWatcher = { ? `${shown} of ${total.toLocaleString()} samples described by "${label}"` : `${shown} sample${shown === 1 ? '' : 's'} described by "${label}"`; + // #172 Inc 1 (Codex round-4 P1): bail if a newer list producer took the + // panel while this concept query was in flight. + if (!conceptOwnsPanel()) return; const sampEl = document.getElementById('samplesSection'); if (sampEl) { let h = headingHTML(` (${ofTotal})`); diff --git a/tests/playwright/pin-overlay.spec.js b/tests/playwright/pin-overlay.spec.js new file mode 100644 index 0000000..21922a9 --- /dev/null +++ b/tests/playwright/pin-overlay.spec.js @@ -0,0 +1,339 @@ +// #172 pin-overlay Inc 1 (option C): after a search completes the located +// results are rendered as a temporary pin overlay on the globe +// (viewer.searchResultPoints), independent of the H3 cluster / sample-point +// layers. These specs assert population, exact set equality with the located +// displayed rows, the 50 cap, pin-set replacement, and the snapshot-semantics +// clear lifecycle end-to-end against the published data. +// +// Run (branch dispatch honors the config's TEST_URL; BASE_URL is a manual +// fallback, production the last resort — a dispatch that sets TEST_URL tests +// the worktree, not production, per Codex round-1 P1.4f): +// TEST_URL=https:// npx playwright test tests/playwright/pin-overlay.spec.js +// +// Test hooks (both set in the viewer cell): +// window.__searchPins() → [{ pid, lat, lng }] read-only snapshot. +// window.__clickSearchPin(i) → replays the on-globe result-pin click ceremony +// (shared helper + pid hash push) by index, since +// Cesium canvas picking isn't feasible in Playwright. +// Ground truths reuse the FTS suite's 202608 index facts: 'pottery Cyprus' → +// 1,305 matches; 'basalt' → 785 — both exceed the LIMIT 50 display cap, so the +// displayed (and pinned) set is capped at 50. 'ark:/28722/k2000hz7r' is the FTS +// suite's known-present single pid. +// +// ASYNC INTERLEAVING (Codex round-3/4): the "list and pins never diverge across a +// search-vs-cluster race, in EITHER order" invariant is guaranteed by the +// dedicated panel-generation counter (viewer._panelGen). Every list producer +// (doSearch, doDescribedBy, the cluster-click nearby path, hydrateClusterUI) +// captures ++viewer._panelGen at start and only writes list+pins while it still +// holds the latest generation — so whichever producer STARTED LAST wins, and an +// earlier producer that resolves later bails. The pure check is unit-tested as +// panelWriteAllowed() in tests/unit/explorer-utils.test.mjs. A live interleaving +// e2e would be flaky (two concurrent DuckDB queries resolving in a specific order), +// so per review guidance we do not ship one; the Back-after-pin-click test below +// exercises the deterministic half of the lifecycle. + +const { test, expect } = require('@playwright/test'); + +// URL precedence (Codex round-4 P2): honor an explicit env override, else fall +// back to Playwright's configured baseURL via a RELATIVE goto (playwright.config.js +// resolves TEST_URL || http://localhost:5860). NO hard-coded production fallback, +// so a branch dispatch verifies the rendered worktree, not deployed production. +// NOTE (divergence): tests/playwright/fts-v1.spec.js still hard-codes a production +// BASE_URL fallback; that spec is out of scope for this branch and left unchanged. +const BASE = process.env.TEST_URL || process.env.BASE_URL || ''; +const explorerUrl = (query = '') => + BASE ? `${BASE}/explorer.html${query}` : `/explorer.html${query}`; + +// Generous flake protection matching the FTS suite: a cold CDN edge pays boot +// + lazy module import + sidecar/shard fetches before the first search lands. +test.describe.configure({ retries: process.env.CI ? 1 : 0 }); + +// [{ pid, lat, lng }] snapshot of the live overlay. +const pins = (page) => page.evaluate(() => (window.__searchPins ? window.__searchPins() : [])); + +// PIDs of the LOCATED search-result rows in the side panel. Rows carry +// data-lat="null"/"" when the match has no coordinates (world-scope LEFT JOIN); +// those are exactly the rows the overlay skips. +const locatedRowPids = (page) => + page.$$eval('#samplesSection .sample-row', (els) => + els + .filter((e) => { + const la = e.dataset.lat, ln = e.dataset.lng; + return la && la !== 'null' && la !== 'undefined' + && ln && ln !== 'null' && ln !== 'undefined'; + }) + .map((e) => e.dataset.pid)); + +const displayedRowCount = (page) => + page.$$eval('#samplesSection .sample-row', (els) => els.length); + +const searchState = (page) => page.evaluate(() => { + const sf = window.__searchFilter || {}; + return { + active: sf.active, + total: sf.total, + term: sf.term, + substrate: sf.substrate, + resultsText: document.getElementById('searchResults')?.textContent || '', + panelText: document.getElementById('samplesSection')?.textContent || '', + }; +}); + +const sortJoin = (arr) => [...arr].sort().join('|'); + +async function submitSearch(page, term) { + await page.fill('#sampleSearch', term); + await page.locator('#searchSubmitBtn').first().click(); +} + +// Wait until the results line settles on a terminal state (not the transient +// "Searching…"/"Building…" messages). +async function waitSearchSettled(page) { + await page.waitForFunction(() => { + const s = (document.getElementById('searchResults')?.textContent || '').trim(); + return s.length > 0 && !/^(searching|building)/i.test(s); + }, null, { timeout: 120_000 }); +} + +async function waitPinsAtLeast(page, n) { + await page.waitForFunction( + (min) => (window.__searchPins ? window.__searchPins().length : 0) >= min, + n, { timeout: 120_000 }); +} + +test.describe('search-result pin overlay (#172 Inc 1)', () => { + test.describe.configure({ timeout: 120_000 }); + + test.beforeEach(async ({ page }) => { + await page.goto(explorerUrl(), { timeout: 90_000 }); + await page.waitForSelector('.samples-table tbody tr[data-pid]', { timeout: 120_000 }); + // Pin overlay starts empty before any search. + expect((await pins(page)).length).toBe(0); + }); + + test('local-many: pins are EXACTLY the located displayed rows, capped at 50', async ({ page }) => { + await submitSearch(page, 'pottery Cyprus'); + await waitPinsAtLeast(page, 1); + await waitSearchSettled(page); + + const pinList = await pins(page); + const pinPids = pinList.map((p) => p.pid); + const located = await locatedRowPids(page); + const displayed = await displayedRowCount(page); + + // Cap: at most the LIMIT 50 display set, and never more than displayed. + expect(pinPids.length).toBeGreaterThan(0); + expect(pinPids.length).toBeLessThanOrEqual(50); + expect(pinPids.length).toBeLessThanOrEqual(displayed); + // Documented total (1,305) exceeds the LIMIT 50 cap, so it must bind exactly. + expect(displayed).toBe(50); + // Exact set equality: one pin per located displayed row, no more, no fewer. + expect(sortJoin(pinPids)).toBe(sortJoin(located)); + // Every pin carries real coordinates (never coerced to 0,0 placeholders). + for (const p of pinList) { + expect(typeof p.lat).toBe('number'); + expect(typeof p.lng).toBe('number'); + } + }); + + test('one: a single-pid query yields one located row and one pin', async ({ page }) => { + await submitSearch(page, 'ark:/28722/k2000hz7r'); + await waitSearchSettled(page); + await waitPinsAtLeast(page, 1); + + const state = await searchState(page); + // Real result, not a build failure. + expect(state.resultsText).not.toMatch(/couldn't build|search error/i); + + const pinPids = (await pins(page)).map((p) => p.pid); + const located = await locatedRowPids(page); + const displayed = await displayedRowCount(page); + + expect(displayed).toBe(1); + expect(pinPids.length).toBe(1); + expect(sortJoin(pinPids)).toBe(sortJoin(located)); + }); + + test('global-many: basalt pins spread over > 30° extent, capped at 50', async ({ page }) => { + await submitSearch(page, 'basalt'); + await waitPinsAtLeast(page, 1); + await waitSearchSettled(page); + + const pinList = await pins(page); + expect(pinList.length).toBeGreaterThan(0); + expect(pinList.length).toBeLessThanOrEqual(50); + // Documented total (785) exceeds the LIMIT 50 cap, so it must bind exactly. + expect(await displayedRowCount(page)).toBe(50); + + // Exact identity: pins are precisely the located displayed rows (same + // assertion the local-many case makes — the plan requires it for "all four"). + const located = await locatedRowPids(page); + expect(sortJoin(pinList.map((p) => p.pid))).toBe(sortJoin(located)); + + const lats = pinList.map((p) => p.lat); + const lngs = pinList.map((p) => p.lng); + const latExtent = Math.max(...lats) - Math.min(...lats); + const lngExtent = Math.max(...lngs) - Math.min(...lngs); + // Globally distributed corpus → the pin cloud spans well past 30°. + expect(Math.max(latExtent, lngExtent)).toBeGreaterThan(30); + }); + + test('zero: a no-hit query is a real zero (not a build failure) with no pins', async ({ page }) => { + await submitSearch(page, 'xyzzyqqqplugh'); + await waitSearchSettled(page); + + const state = await searchState(page); + // Distinguish a genuine empty result set from an infrastructure failure: + // a real zero shows "No results", NOT "Search error"/"couldn't build". + expect(state.resultsText).toMatch(/no results/i); + expect(state.resultsText).not.toMatch(/couldn't build|search error/i); + expect((await pins(page)).length).toBe(0); + }); + + test('new search tears down old rows+pins at submit, then renders the new set', async ({ page }) => { + await submitSearch(page, 'pottery Cyprus'); + await waitPinsAtLeast(page, 1); + const firstPinPids = (await pins(page)).map((p) => p.pid); + const firstRowCount = await displayedRowCount(page); + expect(firstPinPids.length).toBeGreaterThan(0); + expect(firstPinPids.length).toBeLessThanOrEqual(50); + expect(firstRowCount).toBeGreaterThan(0); + + // Entry teardown (Codex round-5/6): the submit's click handler is a plain + // `() => doSearch(...)`, and doSearch runs SYNCHRONOUSLY up to its first await + // — the _panelGen bump, pin removeAll, and row innerHTML='' all execute before + // any await. So drive the submit AND observe in ONE page.evaluate: the old rows + // and pins are already gone the instant the click dispatch returns, BEFORE the + // new results render. Deterministic — no timing race, and it CANNOT pass via + // the later re-render (we read before yielding to the event loop). + const observed = await page.evaluate(() => { + const input = document.getElementById('sampleSearch'); + input.value = 'basalt'; + input.dispatchEvent(new Event('input', { bubbles: true })); + document.getElementById('searchSubmitBtn').click(); + // click -> doSearch entry teardown has run synchronously by now. + return { + rows: document.querySelectorAll('#samplesSection .sample-row').length, + pins: window.__searchPins().length, + }; + }); + expect(observed.rows).toBe(0); // old rows torn down at submit, mid-build + expect(observed.pins).toBe(0); // old pins torn down with them + + // Let the second search complete, then assert the NEW set rendered. + await page.waitForFunction(() => + /results for "basalt"/i.test(document.getElementById('searchResults')?.textContent || ''), + null, { timeout: 120_000 }); + await waitPinsAtLeast(page, 1); + const secondPinPids = (await pins(page)).map((p) => p.pid); + const secondLocated = await locatedRowPids(page); + + expect(secondPinPids.length).toBeGreaterThan(0); + expect(secondPinPids.length).toBeLessThanOrEqual(50); + // Disjoint corpora → the PIN set changed. + expect(sortJoin(secondPinPids)).not.toBe(sortJoin(firstPinPids)); + // Exact final identity: the pins are precisely the new search's located rows. + expect(sortJoin(secondPinPids)).toBe(sortJoin(secondLocated)); + }); + + test('committed empty search clears the pins (snapshot lifecycle)', async ({ page }) => { + await submitSearch(page, 'pottery Cyprus'); + await waitPinsAtLeast(page, 1); + expect((await pins(page)).length).toBeGreaterThan(0); + + // Empty submit is a committed clear path — doSearch clears the overlay AND + // the results list before the too-short early return, so they clear TOGETHER + // (Codex round-3: assert the list too, not just the pins). + await submitSearch(page, ''); + await page.waitForFunction(() => + (window.__searchPins ? window.__searchPins().length : -1) === 0 + && document.querySelectorAll('#samplesSection .sample-row').length === 0, + null, { timeout: 60_000 }); + expect((await pins(page)).length).toBe(0); + expect(await displayedRowCount(page)).toBe(0); + }); + + test('Back after a pin click clears BOTH the list and the pins', async ({ page }) => { + // These predicates run in the BROWSER (passed directly to waitForFunction), + // so they must be self-contained — only browser globals, no Node closure. + const hasHashPid = () => new URLSearchParams((location.hash || '').slice(1)).has('pid'); + const noHashPid = () => !new URLSearchParams((location.hash || '').slice(1)).has('pid'); + const settledNoPidHash = () => { + const p = new URLSearchParams((location.hash || '').slice(1)); + return p.has('v') && !p.has('pid'); + }; + + await submitSearch(page, 'pottery Cyprus'); + await waitPinsAtLeast(page, 1); + // Wait for the auto-fly to SETTLE into a globe hash that has no pid, so Back + // has a deterministic no-selection entry to return to (Codex round-4 P2 — + // otherwise the previous history entry can be hashless / flight-cancelled). + await page.waitForFunction(settledNoPidHash, null, { timeout: 90_000 }); + + const beforePins = (await pins(page)).map((p) => p.pid); + const displayedBefore = await displayedRowCount(page); + expect(beforePins.length).toBeGreaterThan(0); + expect(displayedBefore).toBeGreaterThan(0); + + // A result-pin click selects the sample and pushes a pid hash, while + // PRESERVING the list and pins (verified here). + const clicked = await page.evaluate(() => window.__clickSearchPin(0)); + expect(clicked).toBe(true); + await page.waitForFunction(hasHashPid, null, { timeout: 60_000 }); + await page.waitForFunction(() => { + const cs = document.getElementById('clusterSection'); + return cs && /

\s*Sample\s*<\/h4>/i.test(cs.innerHTML); + }, null, { timeout: 60_000 }); + expect((await pins(page)).length).toBe(beforePins.length); + expect(await displayedRowCount(page)).toBe(displayedBefore); + + // Back → the no-pid hash → the hashchange handler clears BOTH the preserved + // list and the pin overlay (the round-2 sync fix; regression guard). + await page.goBack(); + await page.waitForFunction(noHashPid, null, { timeout: 60_000 }); + await page.waitForFunction(() => + (window.__searchPins ? window.__searchPins().length : -1) === 0 + && document.querySelectorAll('#samplesSection .sample-row').length === 0, + null, { timeout: 60_000 }); + expect((await pins(page)).length).toBe(0); + expect(await displayedRowCount(page)).toBe(0); + }); + + test('selecting a located result row selects it AND preserves the list + pins', async ({ page }) => { + await submitSearch(page, 'pottery Cyprus'); + await waitPinsAtLeast(page, 1); + const before = (await pins(page)).map((p) => p.pid); + const displayedBefore = await displayedRowCount(page); + + // Pick a DEMONSTRABLY LOCATED row (numeric data-lat/lng, not the string + // "null") — a coord-less row's handler returns immediately, so clicking it + // would prove nothing (Codex round-2 P2). + const located = await page.$$eval('#samplesSection .sample-row', (els) => + els + .filter((e) => { + const la = e.dataset.lat, ln = e.dataset.lng; + return la && la !== 'null' && la !== 'undefined' + && ln && ln !== 'null' && ln !== 'undefined'; + }) + .map((e) => ({ pid: e.dataset.pid, label: (e.querySelector('.sample-label')?.textContent || '').trim() }))); + expect(located.length).toBeGreaterThan(0); + const target = located[0]; + + // Click a NON-LINK child (the source badge) so the row handler runs — its + // own handler bails on clicks that land on the label. + await page.locator(`#samplesSection .sample-row[data-pid="${target.pid}"] .source-badge`).first().click(); + + // Assert the selection ACTUALLY happened (the sample card now shows this + // sample) with a condition-based wait BEFORE checking preservation. + await page.waitForFunction((label) => { + const cs = document.getElementById('clusterSection'); + if (!cs) return false; + return /

\s*Sample\s*<\/h4>/i.test(cs.innerHTML) && cs.textContent.includes(label); + }, target.label, { timeout: 60_000 }); + + // Row selection must NOT tear down the results list or its pin snapshot. + const after = (await pins(page)).map((p) => p.pid); + expect(await displayedRowCount(page)).toBe(displayedBefore); + expect(sortJoin(after)).toBe(sortJoin(before)); + }); +}); diff --git a/tests/unit/explorer-utils.test.mjs b/tests/unit/explorer-utils.test.mjs index 47aa175..50aadba 100644 --- a/tests/unit/explorer-utils.test.mjs +++ b/tests/unit/explorer-utils.test.mjs @@ -4,7 +4,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { escapeHtml, searchTerms, parseNum, csvParamValues, sourceUrl, readHash, - facetCountsDisplayState, formatPlaceName, + facetCountsDisplayState, formatPlaceName, panelWriteAllowed, } from '../../assets/js/explorer-utils.js'; test('escapeHtml escapes the five HTML-significant chars; nullish -> ""', () => { @@ -115,3 +115,17 @@ test('facetCountsDisplayState: index ready but the count QUERY itself failed -> test('facetCountsDisplayState: ready + fallthrough is not a state the caller produces, but resolves safely', () => { assert.equal(facetCountsDisplayState('ready', 'fallthrough'), 'unavailable'); }); + +test('panelWriteAllowed: a producer may write only while it holds the latest panel generation (#172 Inc 1)', () => { + // A captured generation equal to the current one → this producer still owns + // the #samplesSection list and may write list + pins. + assert.equal(panelWriteAllowed(1, 1), true); + assert.equal(panelWriteAllowed(7, 7), true); + // A newer producer has since bumped viewer._panelGen → the older producer's + // captured generation is stale and it must bail (closes the search-vs-cluster + // race in BOTH orders: whoever started last holds the highest generation). + assert.equal(panelWriteAllowed(1, 2), false); + assert.equal(panelWriteAllowed(2, 1), false); + // Strict identity — no coercion surprises. + assert.equal(panelWriteAllowed(0, 0), true); +});