Skip to content

Explorer: search-result pin overlay, Inc 1 (option C, #172 track)#337

Merged
rdhyee merged 15 commits into
isamplesorg:mainfrom
rdhyee:feat/pin-overlay-inc1
Jul 18, 2026
Merged

Explorer: search-result pin overlay, Inc 1 (option C, #172 track)#337
rdhyee merged 15 commits into
isamplesorg:mainfrom
rdhyee:feat/pin-overlay-inc1

Conversation

@rdhyee

@rdhyee rdhyee commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🤖 rbotyee (Claude, operated by @rdhyee) — Raymond's intent: start the pin-overlay work the #172 BLUF announced ("can we start on the pin-overlay now?", 2026-07-17 2:26pm). What I did: plan → 6 adversarial Codex review rounds → this PR; staging verification to follow before merge.

BLUF (plain English)

After a search, the Explorer now drops pins on the globe showing where your matches are — one hollow ring per located result, colored by source, capped at the 50 displayed results. Click a pin and it behaves exactly like clicking a sample. This is Increment 1 of the "option C" search UI the team adopted in May (#164/#165): purely additive — search still filters the table/facets exactly as before. The speed-focused Increment 2 (search stops refiltering every surface, behind a searchui=pin flag) comes next; this PR deliberately does not change any filtering behavior. The #172 gate stays open, as does #165.

What's in it

  • viewer.searchResultPoints — third primitive collection (above clusters and sample points), hollow-ring styling per the EXPLORER_STATE.md option-C contract; hover + click parity via a shared selectSearchResult() helper (row clicks and pin clicks use the same ceremony)
  • Panel-ownership invariant (the real engineering in this PR): any write to the results-panel list — clear or replace, sync or async — advances viewer._panelGen; async producers capture at start and may only write if still current. 11 audited sites; pure panelWriteAllowed() helper in assets/js/explorer-utils.js with a unit test. This closes a pre-existing race family (search rows vs nearby-samples rows interleaving across awaits) that pins made visible.
  • Snapshot semantics: pins mirror the rendered list — populated together, torn down together (new-search entry teardown included)
  • Camera: fly-to targets the first located result (null-safe); fit-to-bounds deferred to Inc 2 (documented in the plan)
  • tests/playwright/pin-overlay.spec.js: 8 tests — four contract shape cases (zero/one/local-many/global-many with exact pin↔row identity), pin-set replacement, committed-clear, row-click preservation with real-selection assertion, Back-after-pin-click regression (via a navigator.webdriver-gated hook that replays the real ceremony), deterministic same-tick entry-teardown observation; spec honors Playwright baseURL (no production fallback)
  • Plan doc: PLAN_PIN_OVERLAY_2026-07-17.md (amended through the rounds)

Review trail (6 Codex rounds, all findings addressed)

Round Verdict What it caught
Plan NOT-LGTM P0: Inc 2's proposed seam was wrong (__searchFilter.active has 7 independent consumers) — corrected in plan before any code
1 NOT-LGTM 5 P1: depth-test bleed-through, lifecycle holes, in-map card parity, weak tests, plan drift
2 NOT-LGTM 1 P1: four more reachable list-teardown paths orphaning pins
3 NOT-LGTM 1 P1: async race (cluster hydration vs newer search)
4 NOT-LGTM 1 P1: the mirror race (search vs newer cluster) → dedicated _panelGen
5 NOT-LGTM 2 P1: sync clears must advance the generation; entry teardown
6 production 0 P0/P1 1 test-only P2 → fixed, confirmed LGTM

Each round also verified: OJS cell compilation, quarto render, 111 node unit tests, 40 frontend-derived tests, git diff --check.

Known follow-ups (not in this PR)

  • Pre-existing writeQueryState incoherence (draft-cleared input + facet toggle drops search= from URL while the runtime filter survives) — issue to be filed
  • fts-v1.spec.js still has a hard-coded production URL fallback — separate small fix
  • Inc 2: searchui=pin mode via a searchFiltersSurfaces() predicate + fit-to-bounds camera

Verification before merge

Live staging run (fork Pages dispatch from this branch) + browser acceptance of the four shape cases and Back-after-pin-click. Will be posted in comments.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y

rdhyee and others added 15 commits July 17, 2026 14:36
…nc 1, option C)

After any committed search, render the located displayed results as a
distinct hollow-ring pin overlay on the globe, independent of the H3
cluster and sample-point layers. Purely additive — global filtering,
facet counts, and camera behavior are unchanged in Inc 1.

- viewer cell: viewer.searchResultPoints PointPrimitiveCollection added
  to scene.primitives AFTER h3Points/samplePoints (z-order rule); always
  shown. window.__searchPins() test hook exposes the live pin count.
- hover: result pins label like sample points (meta.label || meta.pid).
- click: new meta.type === 'searchResult' branch runs the shared
  selectSearchResult() ceremony (freshness token, card, selection state,
  flight, lazy detail) but PRESERVES the #samplesSection results list;
  like the sample branch it opens the in-map card and writes the pid hash.
- selectSearchResult(sample, {fly, inMapCardAt}) extracts the former
  search-row click ceremony; both the row click and the pin click use it
  (no shallow duplicate). Row-click behavior is unchanged.
- doSearch: populate one pin per LOCATED displayed result (skip null
  lat/lng — never coerce to (0,0)); cap inherited from LIMIT 50. Count
  line discloses "(M located, pinned on globe)" when located < displayed.
- lifecycle: clear the overlay on committed clear paths only — new/empty
  submit (top of doSearch), clearActiveSearchFilter, and a committed
  concept filter (doDescribedBy). Draft input edits never clear.

Camera fit-to-bounds is deferred to Inc 2 per the amended plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
…Inc 1)

Models tests/playwright/fts-v1.spec.js conventions (BASE env, explorer.html
boot, wait on the samples-table, drive #sampleSearch + #searchSubmitBtn,
poll via window.__searchPins()). Cases: local-many (pottery Cyprus → pins
in (0,50] and <= displayed), zero (no-hit → 0 pins), new-search replaces
the pin set, and committed empty search clears the pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
… overlay

- P1.1 depth test: pins now use the bounded POINT_DEPTH_TEST_DISTANCE instead
  of Number.POSITIVE_INFINITY, which the file documents as causing
  back-hemisphere bleed-through (isamplesorg#185). The hover-label tooltip keeps Infinity
  (a tooltip should not be depth-tested) — unchanged.
- P1.2 lifecycle (snapshot semantics): the overlay is a snapshot of the
  rendered results list and reconciles with the LIST lifecycle, not filter
  state. Added clears where the globe sample/cluster clicks tear down the list;
  documented the policy at the populate site; corrected the clearActiveSearchFilter
  comment (its only caller is the concept-chip clear button). Source/facet
  toggles while a search is active leave list+pins stale by design.
- P1.3 in-map card parity: pin clicks now get the FULL in-map detail hydration
  (material/feature/specimen/thumbnail) by reusing openInMapCardForSample()
  (extended with an optional anchor pixel) instead of a description-only fetch —
  no duplicated query. Removed the double freshness bump: selectSearchResult
  takes the caller's token when provided.
- P1.5 camera: fly to the first LOCATED result with explicit null checks (0°
  coords valid); results[0] can be coord-less in world scope. Fit-to-bounds
  stays deferred to Inc 2.
- P2 hover: source-colored hover-label background for both sample points and
  search-result pins (contract's "source badge color"); cluster/plain hovers
  restore the default background.
- P1.4a test hook: window.__searchPins() now returns a guarded read-only
  [{pid,lat,lng}] snapshot (re-reads the collection per call; [] if missing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
- Exact set equality between pin pids and located displayed-row pids
  (local-many pottery Cyprus), not just bounds.
- "new search replaces pins" compares PIN pid sets before/after, not
  side-panel pids.
- Zero case (xyzzyqqqplugh) asserts a REAL zero (No results, active/complete)
  and fails on build-failure states.
- Added the one-result case (single pid -> 1 located row, 1 pin) and the
  global-many case (basalt -> pin cloud spans > 30 deg extent).
- Added a results-preserved case: a row click keeps the list and pin set.
- Snapshot hook enables identity/position assertions.
- URL precedence now honors the config's TEST_URL first (then BASE_URL, then a
  production fallback) so a branch dispatch tests the worktree, not production.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
Codex round-1 P1.5/P1.2: the live plan now states that Inc 1 ships only the
first-located fly-to (fit-to-bounds and its local-many zoom acceptance are
deferred to Inc 2), and documents the snapshot-semantics lifecycle plus the
pre-existing writeQueryState search= drop left for a follow-up issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
Snapshot semantics require the result-pin overlay to reconcile with the
rendered-list lifecycle at EVERY teardown, not just the two direct globe-click
branches. Added guarded `viewer.searchResultPoints.removeAll()` at the
remaining reachable teardowns:

- hydrateClusterUI() — the choke point that replaces the list with nearby
  samples; covers source/facet selection-revalidation, hashchange h3 restore,
  and boot h3 deep-link.
- source-filter handler: selectedH3-gone and selectedPid-gone clears (the
  "source filtering invalidates a selected search result" path).
- facet-filter handler: selectedH3-gone clear.
- hashchange: h3-invalid clear and the no-selection branch (the
  Back-after-pin-click repro that orphaned pins).

Owner-aware list preservation is out of scope for Inc 1; clearing both together
keeps the documented invariant simply. All #samplesSection teardown/replace
sites are now covered; the render paths (doSearch/doDescribedBy) and the
hashchange state.pid branch legitimately keep the list and its pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
…ound-2 P2)

- Row-preservation test now picks a demonstrably located row (numeric
  data-lat/lng, not "null"), clicks a NON-LINK child (the source badge) so the
  row handler actually runs, and asserts the selection happened (the sample
  card shows the row) with a condition-based wait BEFORE checking that the list
  and pin set are preserved. Previously it could pass with no row handler at all.
- local-many (pottery Cyprus) and global-many (basalt) now assert exactly 50
  displayed rows — their documented totals exceed 50, so the LIMIT 50 cap binds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
…dex round-3 P1)

The Round-2 clears were correct synchronously but not across awaits: a cluster
hydration could clear pins, await its nearby query, and then (after a search had
rendered its rows and pins) call updateSamples() and replace only the list,
orphaning the search pins. doSearch never invalidated the selection freshness
token, so the older hydration stayed "fresh".

Panel ownership via the EXISTING freshness token (no new generation system):
- doSearch() and doDescribedBy() bump freshSelectionToken(viewer) at entry — a
  committed search/concept is a new panel owner, so any in-flight selection
  hydration becomes stale and bails at its post-await isStale() check. Their own
  post-await renders stay guarded by searchId/_searchSeq (the two token systems
  guard different things — search build vs selection panel — and both hold; no
  interaction, verified against the boot search+pid path).
- Every post-await list-mutation site now writes list + pins as ONE owned
  operation: the direct cluster-click nearby path and hydrateClusterUI() both
  re-clear pins AFTER the isStale() check, immediately before updateSamples() —
  so a search that populated pins during the await can't orphan them.
- clearActiveSearchFilter() also bumps the token (ownership) and now clears the
  list, keeping list+pins together.

Deterministic hole (Codex): the empty-submit early return now clears
#samplesSection so the list and the overlay clear together (snapshot contract).

Adds a test-only window.__clickSearchPin(i) hook that replays the real pin-click
ceremony (shared helper + pid hash push) so specs can exercise pin selection and
Back-navigation without Cesium canvas picking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
- Empty-submit test now asserts the results LIST is cleared (zero .sample-row),
  not just zero pins — list and pins clear together.
- New Back-after-pin-click regression test: search -> __clickSearchPin (real
  ceremony: selects + pushes pid hash, list+pins preserved) -> page.goBack() ->
  assert the no-selection hash clears BOTH the list and the overlay.
- Global-many (basalt) now asserts exact pin-pids == located-row-pids identity,
  matching local-many (the plan's "all four" wording).
- Header documents that the async-interleaving invariant is guaranteed by the
  freshness-token ownership audit, not a flaky timing-dependent e2e (per review
  guidance not to ship a flaky interleaving test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
…orders (Codex round-4 P1)

Round-3 bumped _selGen but doSearch's render checked only _searchSeq, so the
reverse order (search starts, a newer cluster click starts, the cluster finishes
first and the older search then overwrites its rows/pins) was still open.

Add a dedicated list-ownership generation, viewer._panelGen:
- Every list producer — doSearch, doDescribedBy, the direct cluster-click nearby
  path, and hydrateClusterUI — captures `const myPanelGen = ++viewer._panelGen`
  at START, and every post-await list+pin write additionally checks
  panelWriteAllowed(myPanelGen, viewer._panelGen) (search renders keep _searchSeq
  too — both must hold). Whoever STARTED LAST holds the highest generation, so an
  earlier producer that resolves later bails. Closes both orders.
- The _selGen ownership bumps stay (they guard selection/card work).
- panelWriteAllowed(captured, current) is a pure helper in assets/js/
  explorer-utils.js so the invariant is unit-testable (added a unit test; 111
  pass) and importable into the OJS runtime.

Verified pid-deep-link-is-not-a-producer: the boot AND hashchange pid paths call
updateSampleCard + openInMapCardForSample (card only) and never bump _panelGen,
so boot search+pid still renders the search list.

The synchronous selection-invalidation clears (source/facet/hashchange) are NOT
given _panelGen: they are terminal synchronous writes, and even if an older async
producer completes after one of them it writes list+pins together atomically, so
the "list and pins never diverge" invariant still holds (Codex verified those
sync clears in round 2).

Also (round-4 P2): __clickSearchPin now replays the FULL pin ceremony (passes
inMapCardAt = canvas centre for the rich floating-card hydration) and is installed
only under navigator.webdriver so it cannot run in a normal session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
…ex round-4 P2)

- URL resolution now uses process.env.TEST_URL || BASE_URL || '' and a relative
  goto when unset, so Playwright's config baseURL (TEST_URL || localhost) drives
  a branch dispatch — no hard-coded production fallback that would silently test
  deployed prod. Noted the fts-v1.spec.js divergence (left unchanged; out of scope).
- Back-after-pin-click test: wait for the auto-fly to SETTLE into a globe hash
  with a `v` and no `pid` before clicking, so Back has a deterministic
  no-selection entry; after goBack wait on the no-pid hash before asserting the
  list+pins cleared. Predicates are self-contained (browser globals only) so they
  serialize into page.waitForFunction cleanly.
- Header documents that the search-vs-cluster interleaving invariant is proven by
  the panel-generation ownership audit (panelWriteAllowed, unit-tested), not a
  flaky timing e2e.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
…s settle (Codex round-5)

P1.1 UNIFIED RULE — every write to the rendered panel list (#samplesSection),
clear or replace, sync or async, now advances viewer._panelGen. Async producers
also capture it and only write while current; synchronous clears just advance it
so a slow in-flight search/hydration can no longer resurrect a panel the user has
since replaced or cleared. Stated once at the _panelGen init. Audited every
#samplesSection write; the sync clears that previously did NOT bump now do:
  - ordinary sample-click list clear (viewer cell)
  - source-filter invalidation clears (selectedH3-gone, selectedPid-gone)
  - facet-filter invalidation clear (selectedH3-gone)
  - hashchange unknown-H3 clear and no-selection (Back) clear
  - clearActiveSearchFilter (concept-chip clear)
The async producers (doSearch, doDescribedBy, cluster-click nearby, hydrateClusterUI)
already advance via their ++capture; the pid deep-link stays card-only (never bumps).

P1.2 — a committed new search/concept now tears the panel DOWN at entry: clear the
old rows AND pins together (under the freshly captured generation), so no stale
unpinned rows linger for seconds during the async build; the panel shows the
building status until the atomic replacement lands. The entry clear's bump IS the
producer's captured gen (one ++), so the search still owns its own subsequent
render. Removed the now-redundant empty-branch-only list clear.

P2 — search-status settle on lost ownership: when doSearch bails because
panelWriteAllowed fails, it now settles its own "Searching…" line to '' iff its
searchId still equals _searchSeq (no newer search owns the text), so the status
can't persist forever after a cluster takes the panel. If a newer search owns the
text, it touches nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
… round-5)

The new-search test now captures the first search's rendered row pids and, after
submitting a disjoint second search, waits for the first row's pid to disappear —
proving the entry-clear tears the old rows down at submit (it becomes true at the
synchronous entry-clear and stays true), not just that pins eventually differ.
Also asserts the rendered row set (not only the pin set) changed after replacement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
…nd-6 P2)

The prior entry-teardown assertion (old pid disappears) could also pass once the
second render replaced the rows. Replace it with a race-free construction: 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='' have no interposing await — verified), so drive the second submit AND
read rows/pins in ONE page.evaluate. The old rows and pins are observed gone the
instant the click dispatch returns, mid-build, before the new results can render —
so the assertion cannot pass without the entry teardown. Also add the exact final
identity assertion: pin pids === the new search's located row pids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nohddj7oXLnkJ4P9Cibg8Y
@rdhyee rdhyee added the CC+Codex+LGTM Claude and Codex both reached LGTM; ready for human review label Jul 17, 2026
@rdhyee

rdhyee commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

🤖 rbotyee (Claude, operated by @rdhyee) — staging acceptance run, 2026-07-17 ~4:45pm PT, live browser against the branch build at rdhyee.github.io (fork Pages dispatch).

Live acceptance: ALL PASS

Case Result
local-many ("pottery Cyprus", 1,305 total) ✅ 50 pins = 50 located rows, exact pid identity, camera on Cyprus, hollow-ring pins visibly layered above cluster dots
global-many ("basalt", 785 total) ✅ 39 pins for 50 displayed rows — the 11 coord-less world results correctly unpinned (located-only semantics); exact identity; zero overlap with prior pottery pin set (clean replacement); 38° extent; fly-to-first-located
entry teardown ✅ observed in the same synchronous turn as submit: rows=0, pins=0 mid-build (the round-5/6 invariant, live)
zero ("xyzzyqqqplugh") ✅ 0 pins, real "No results" status (not a build failure)
committed-empty submit ✅ 0 pins, 0 rows, ?search= removed from URL

Console: 0 errors across the whole run. Not exercised live: pin-click and Back-after-pin-click (the __clickSearchPin hook is navigator.webdriver-gated and this manual browser reports webdriver=false); both are covered by the Playwright spec (8 tests) and the pin-click path shares the row-click ceremony verified interactively.

Ready for merge on Raymond's call.

@rdhyee
rdhyee merged commit b4097bc into isamplesorg:main Jul 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CC+Codex+LGTM Claude and Codex both reached LGTM; ready for human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant