explorer: dynamic cross-filter facet counts#155
Conversation
Implements path A from issue isamplesorg#154 (Codex-resolved plan): facet counts recalculate when filters are toggled, matching the OpenContext UX Eric Kansa flagged. Counts reflect the full source + material + context + object_type filter combination; search and camera viewport remain out of v1 scope per the issue. How it works: - All four filter groups (source legend + material + sampled feature + specimen type) carry uniform `data-facet` / `data-value` count spans + `.facet-row` parents so refreshFacetCounts() updates text in place without rebuilding checkbox HTML (which would lose mid-interaction selections). - One debounced refreshFacetCounts() (~250 ms) with a generation/stale- result guard, mirroring the existing loadViewportSamples and refreshResultsTable patterns. - Strategy: * No filters active → restore baseline counts from facet_summaries (stashed on viewer._baselineCounts after initial load). * Exactly one facet value selected → cache lookup against isamples_202601_facet_cross_filter.parquet (~6 KB, single-filter pre-aggregation; one matching row per target facet value). * Anything else → on-the-fly: four concurrent GROUP BY queries on sample_facets_v2, each excluding the column being recomputed. - The selected facet's own values keep baseline counts (so the user can see what other values WOULD yield if they switched their selection within that group). - Zero-count rows dim (opacity 0.4) rather than hide; less layout churn and clearer signal that the selection eliminated those values. - During recompute the counts get a subtle italic .recomputing class so users see something is updating. Source counts are sample-level (matching facet_summaries / facets_url) and not H3 cluster dominant_source counts; the cluster-zoom caveat in "How It Works" already covers the dominant_source semantics for the globe view. Smoke-test: 0 JS exceptions, 0 console errors, 0 network failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Semantics discussion (Raymond ↔ Claude, 2026-04-30)Anchoring the within-facet semantics + "count next to each value" before continuing the patch. Raymond
ClaudeYes — and your intuition is the standard faceted-search behavior, which is good news. But there's a subtle ambiguity in the "count if you add this" part that's worth pinning down before I touch the code. Within-facet semantics you described (empty = all; 1 = just that; multi = OR) — this is what the SQL filter logic already does in PR #155. "Count if you add this" — two readings: (A) Per-value count under cross-dim filters. Next to "Glass" we display the count of samples whose
(B) Hypothetical-result-after-add count. Next to "Glass" we display the new total result set size if Glass were added to whatever's already selected in Material.
I'd go with (A). It's what every major faceted UI does (OpenContext included), it answers "how big is each option?" which is what users actually want for picking next, and (B)'s numbers move in lockstep so they don't help comparison. Bug in my current PR #155 to fix regardless: when ANY value in a dim is selected, I currently fall back to baseline counts for that dim's own values (the unfiltered facet_summaries). I should be applying cross-dim filters to that dim too, just excluding the dim's own current selection. So if you've selected Source=OpenContext, the Material list values should each show "count of that material under OpenContext" — not the global counts. That's plain (A). DecisionGoing with A and patching PR #155 to fix the dim-with-selections bug. |
Per semantics (A) confirmed in the PR isamplesorg#155 thread: every dim's value counts should reflect cross-dim filters, not just dims without active selections. Previously refreshFacetCountsNow's on-the-fly path skipped any dim that had selections (paint baseline), which broke multi-filter compositions: with Source=OpenContext + Material=Pottery selected, Material values still showed unfiltered baseline counts instead of "count of each material under Source=OpenContext." Fix: remove the skip in the on-the-fly fallback. The existing buildExcludeWhere() already excludes the dim being recomputed from the WHERE, so every dim's GROUP BY yields the correct (A)-semantic count. Single-active cache path is unchanged: under one active dim with no other filters, recomputing that dim's values = baseline by definition, and the cache is empirically confirmed to not carry rows for the filter-dim's own facet_type. Updated the comment to explain why. Smoke test still PASS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Review findings from the semantics pass anchored at #155 (comment):
I did not run the Quarto render or browser smoke test; this was a code-review pass focused on the linked semantics comment and PR diff. |
…+ debounce race Two fixes from the Codex review of isamplesorg#155: 1. Source filter "0 checked" now means "all" (no filter), matching Raymond's stated intuition for facet UX (PR isamplesorg#155 thread) and the semantics already used by material/context/object_type. Previously sourceFilterSQL returned `AND 1=0` when zero source checkboxes were checked, while refreshFacetCountsNow treated zero-checked as "no active source filter" and restored baseline counts — meaning the UI could show non-zero facet counts while the actual result set was empty. The describeActiveFilters helper already mapped both 0-checked and 4-checked to "source not filtering," so this just unifies sourceFilterSQL with that view. 2. Debounce race fix: refreshFacetCounts() now increments facetCountsReqId synchronously when scheduling, not after the 250 ms debounce fires. Previously, an in-flight count query could resolve during the debounce window of a newer schedule and still pass its generation check (because the gen was unchanged), repainting stale counts. Bumping the gen on schedule invalidates all in-flight queries immediately. Also added an early-stale guard at the top of refreshFacetCountsNow. Smoke test still PASS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Codex re-review finding after commit
The two prior review findings are addressed in current head I did not run Quarto/browser tests for this re-review. |
After the prior fix unifying source semantics so 0 checked == 4 checked == "all sources / no filter," the .disabled styling on the legend rows was still based on the per-checkbox checked state. Result: unchecking all 4 source boxes painted every label dimmed (looked like every source was excluded) while results and counts correctly included all sources. Cosmetically inconsistent. Fix: dim a row only when the source filter is actually narrowing the result set (1–3 checked). 0 or 4 checked → no dimming, matching the "no source filter" semantics. Also reuses the resulting filterIsActive flag for the URL persistence branch a few lines down (was duplicating the `> 0 && < 4` test). Smoke test still PASS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the unified-explorer migration (issue isamplesorg#156). Phases 1-4 (isamplesorg#157-isamplesorg#160) built the unified UI on tutorials/progressive_globe.qmd; this PR promotes it to the canonical site-root URL and retires the old Search Explorer page. Rename + asset-path fix - Move tutorials/progressive_globe.qmd → explorer.qmd at site root. - Adjust source-palette import from `../assets/js/source-palette.js` to `assets/js/source-palette.js` so it resolves on both isamples.org and on rdhyee.github.io/isamplesorg.github.io PR previews. URL param: search, not q - The explorer's URL state now uses `?search=` instead of `?q=`. Quarto's site-wide search reserves `?q=` for its highlight feature and strips the param via history.replaceState before any of our cells run (see docs/site_libs/quarto-search/quarto-search.js). `?search=` is unused by Quarto and survives intact. Redirect stubs at the old URLs - tutorials/progressive_globe.html and tutorials/isamples_explorer.html become preview-safe redirect stubs: new URL('../explorer.html' + search + hash, href) - They forward whatever query string the browser presents. Note: legacy `?q=basalt` URLs lose the search term because Quarto strips `?q=` before our stub script runs (the stub is itself a Quarto-rendered page, so its <head> loads quarto-search.js). Non-q params (sources, material, etc.) and the hash fragment all survive — the only affected URLs are Phase 3 dev test links that were never published. _quarto.yml + internal links - Navbar Interactive Explorer href → explorer.qmd. Search Explorer removed from both the How-to-Use menu and the sidebar. - Update internal links to /explorer.html in index.qmd, how-to-use.qmd, tutorials/index.qmd, about.qmd, data.qmd, design/index.qmd, index_alt.qmd, query-spec.qmd, tutorials/narrow_vs_wide_performance.qmd, and the existing parquet_cesium_isamples_wide redirect stub. Tests - tests/test_explorer.py → tests/test_globe.py targeting /explorer.html. - Selectors updated for the unified DOM-based UI: #sourceFilter, #materialFilter, #contextFilter, #objectTypeFilter, #globeViewBtn / #tableViewBtn (no List view), #maxSamples number input. - Unskip the cross-filter facet tests deferred in isamplesorg#155 — native HTML checkboxes respond to programmatic .click() unlike the old Explorer's OJS Inputs.checkbox. - Add redirect-preserves-params tests for both old URLs (using the current ?search= param, which survives Quarto's q-stripping). - test_navigation.py + test_tutorials_landing.py drop Search Explorer assertions and retarget the globe-loads test to /explorer.html. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#162) * Phase 5: rename Interactive Explorer to /explorer.html with redirects Closes the unified-explorer migration (issue #156). Phases 1-4 (#157-#160) built the unified UI on tutorials/progressive_globe.qmd; this PR promotes it to the canonical site-root URL and retires the old Search Explorer page. Rename + asset-path fix - Move tutorials/progressive_globe.qmd → explorer.qmd at site root. - Adjust source-palette import from `../assets/js/source-palette.js` to `assets/js/source-palette.js` so it resolves on both isamples.org and on rdhyee.github.io/isamplesorg.github.io PR previews. URL param: search, not q - The explorer's URL state now uses `?search=` instead of `?q=`. Quarto's site-wide search reserves `?q=` for its highlight feature and strips the param via history.replaceState before any of our cells run (see docs/site_libs/quarto-search/quarto-search.js). `?search=` is unused by Quarto and survives intact. Redirect stubs at the old URLs - tutorials/progressive_globe.html and tutorials/isamples_explorer.html become preview-safe redirect stubs: new URL('../explorer.html' + search + hash, href) - They forward whatever query string the browser presents. Note: legacy `?q=basalt` URLs lose the search term because Quarto strips `?q=` before our stub script runs (the stub is itself a Quarto-rendered page, so its <head> loads quarto-search.js). Non-q params (sources, material, etc.) and the hash fragment all survive — the only affected URLs are Phase 3 dev test links that were never published. _quarto.yml + internal links - Navbar Interactive Explorer href → explorer.qmd. Search Explorer removed from both the How-to-Use menu and the sidebar. - Update internal links to /explorer.html in index.qmd, how-to-use.qmd, tutorials/index.qmd, about.qmd, data.qmd, design/index.qmd, index_alt.qmd, query-spec.qmd, tutorials/narrow_vs_wide_performance.qmd, and the existing parquet_cesium_isamples_wide redirect stub. Tests - tests/test_explorer.py → tests/test_globe.py targeting /explorer.html. - Selectors updated for the unified DOM-based UI: #sourceFilter, #materialFilter, #contextFilter, #objectTypeFilter, #globeViewBtn / #tableViewBtn (no List view), #maxSamples number input. - Unskip the cross-filter facet tests deferred in #155 — native HTML checkboxes respond to programmatic .click() unlike the old Explorer's OJS Inputs.checkbox. - Add redirect-preserves-params tests for both old URLs (using the current ?search= param, which survives Quarto's q-stripping). - test_navigation.py + test_tutorials_landing.py drop Search Explorer assertions and retarget the globe-loads test to /explorer.html. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix Codex review findings 1. test_baseline_sesar_count_matches_summaries was racy: facet-count spans are present in static HTML before being populated, so wait_for("attached") returned immediately and the test parsed an empty string. Wait until the SESAR span text matches \(\d (i.e., a parenthesised number) before reading. 2. Drop ?maxSamples= from URL state. Phase 3 introduced it to control the globe POINT_BUDGET; Phase 4 added a separate #maxSamples input for the table cap with different defaults (5000 vs 25000) and ranges (1-1000000 vs 1000-100000). The two were never the same concept and conflating them under one URL param meant `?maxSamples=10000` silently affected the globe but not the visible table input, and table-input changes never made it back to the URL. Remove the URL param entirely: globe POINT_BUDGET reverts to the constant DEFAULT_POINT_BUDGET (5000), table input remains a UI-only control. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix explorer facet dimming * Drop search-triggered refreshFacetCounts calls Follow-on to 8559b56 (Fix explorer facet dimming) which decoupled facet counts from the search predicate. After that change, calling refreshFacetCounts on every search keystroke / button click / Enter triggered DB requeries that produced no visible difference (counts no longer depend on search text). Drop those calls. The single refreshFacetCounts() at the end of the cell (initial paint) and the calls from facet checkbox handlers remain — those are still load-bearing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #154.
Implements the path-A plan from #154 (Codex-resolved): when the user toggles a filter, the count next to every other facet value recalculates against the new filter combination. Matches the OpenContext UX Eric Kansa pointed at. Source + material + sampled feature + specimen type all participate. Search and camera viewport stay out of scope per the issue.
Live preview: https://rdhyee.github.io/isamplesorg.github.io/tutorials/isamples_explorer.html?sources=OPENCONTEXT
What changed
Data
cross_filter_url = .../isamples_202601_facet_cross_filter.parquet(the existing ~6 KB single-filter pre-aggregation cache).DOM scaffolding
<label class="facet-row" data-facet="K" data-value="V"><span class="facet-count" data-facet="K" data-value="V">applyFacetCounts(facetKey, countsMap)update text in place without rebuilding checkbox HTML — important because mid-interaction rebuilds would lose user selections..facet-row.zero { opacity: 0.4 }for dim-on-zero, and.facet-count.recomputing { opacity: 0.55; font-style: italic }while a refresh is in flight.Helpers (helpers cell)
applyFacetCounts(facetKey, countsMap | null)— writes count text and toggles.zeroon each row. Null = restore baseline (fromviewer._baselineCounts).markFacetCountsRecomputing()— italicizes counts during a query.refreshFacetCounts()(insidezoomWatcher)Debounced 250 ms with a generation guard (
facetCountsReqId). Three cases:(filter_source, filter_material, filter_context, filter_object_type, facet_type, facet_value, count).GROUP BYqueries onsample_facets_v2, each excluding the column being recomputed:Selected dim's own values keep baseline counts (so the user can switch their selection within that group without the counts collapsing to "now I only have one option").
Wiring
Existing source / material / context / object_type change handlers each gain a
refreshFacetCounts()call alongside the existingrefreshResultsTable(). One initial paint at startup so any?sources=URL param's narrowing is reflected immediately.What's deferred (per #154)
Test plan
quarto render tutorials/isamples_explorer.qmdsucceedsReferences
isamples_202601_facet_cross_filter.parquet— pre-aggregated single-filter cacheisamples_202601_sample_facets_v2.parquet— full per-pid facet table for the on-the-fly fallback🤖 Generated with Claude Code