diff --git a/explorer.qmd b/explorer.qmd index 05fbba0..6448f77 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -68,6 +68,21 @@ format: min-height: 0; aspect-ratio: auto; } + /* Busy indicator while a filter/search/facet update is in flight. + `progress` (arrow + spinner), NOT `wait` (hourglass): nothing freezes — + we're signaling "updating", not "blocked". PAGE-WIDE on purpose: a CSS + cursor only shows where the pointer actually is, and these updates are + triggered with the pointer OFF the map (in the search box / sidebar + checkboxes), so a map-only rule was invisible in practice. The `*` with + !important overrides per-element cursors (text inputs, the Cesium canvas, + buttons) so the signal shows wherever the pointer rests. The + body.explorer-busy class is toggled by the depth-counted busyAcquire/ + busyRelease around the facet-change, source-change, and search/concept + commit paths (with a 120s watchdog failsafe so it can never stick). + Pan/zoom viewport reloads don't set the class yet — deferred follow-up. */ + body.explorer-busy, body.explorer-busy * { + cursor: progress !important; + } /* Slim top-right search overlay (Hana Figma node 222:456). The earlier multi-row treatment (M-1A, PR #200) ate ~480px × ~100px on the left side of the map; this collapses to one row at the right. The @@ -219,6 +234,45 @@ format: max-height: var(--explorer-map-height); overflow-y: auto; } + .filter-chip-host { + position: sticky; + top: 0; + z-index: 3; + } + .filter-chip-host[hidden] { display: none; } + .described-by-chip { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + background: #ede7f6; + border: 1px solid #d1c4e9; + border-radius: 6px; + color: #5e35b1; + font-size: 12px; + font-weight: 600; + padding: 8px 10px; + } + .described-by-chip-label { + min-width: 0; + overflow-wrap: anywhere; + } + .described-by-chip-clear { + flex: 0 0 auto; + background: transparent; + border: 0; + border-radius: 4px; + color: inherit; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 2px 4px; + } + .described-by-chip-clear:hover, + .described-by-chip-clear:focus { + background: rgba(94, 53, 177, 0.12); + outline: none; + } .panel-section { background: #f8f9fa; border-radius: 6px; @@ -231,6 +285,8 @@ format: grid-template-columns: 1fr 1fr; gap: 6px; } + .stats-disclosure { border-top: 0; padding-top: 0; margin-top: 0; } + .stats-body { padding: 4px 0 0; } .stat-box { background: #1a1a2e; color: white; @@ -595,17 +651,25 @@ Circle size = log(sample count). Color = dominant data source.
+
+
+ + +
@@ -3446,13 +3510,36 @@ zoomWatcher = { // whole point of the flag. Depth-counted: class is added on the // 0 → 1 transition and removed on the 1 → 0 transition. let _busyDepth = 0; + // Failsafe so the `progress` cursor (CSS on body.explorer-busy) can NEVER + // stick. Every busyRelease is already in a `finally`, so a throw can't + // strand the flag — but a HUNG promise (a load that never settles/rejects) + // would never reach that finally. This watchdog force-clears the busy + // state after a window far longer than any real update, incl. cold-cache + // loads (which the code notes can take 60–90s). It's re-armed on each + // acquire, so it only ever fires on a genuine leak — and the cursor is + // purely cosmetic, so an early clear is harmless (the map never freezes). + let _busyWatchdog = null; + const BUSY_WATCHDOG_MS = 120000; + function _clearBusyWatchdog() { + if (_busyWatchdog) { clearTimeout(_busyWatchdog); _busyWatchdog = null; } + } function busyAcquire() { if (_busyDepth === 0) document.body.classList.add('explorer-busy'); _busyDepth++; + _clearBusyWatchdog(); + _busyWatchdog = setTimeout(() => { + _busyDepth = 0; + document.body.classList.remove('explorer-busy'); + _busyWatchdog = null; + console.warn('explorer-busy watchdog fired — force-cleared a stuck busy cursor.'); + }, BUSY_WATCHDOG_MS); } function busyRelease() { _busyDepth = Math.max(0, _busyDepth - 1); - if (_busyDepth === 0) document.body.classList.remove('explorer-busy'); + if (_busyDepth === 0) { + document.body.classList.remove('explorer-busy'); + _clearBusyWatchdog(); + } } // --- Source filter change handler --- @@ -3585,6 +3672,7 @@ zoomWatcher = { window.a1dbg?.('apply-search-change', { active: searchIsActive(), mode: getMode() }); busyAcquire(); try { + syncSearchPanelState(); syncFacetNote(); refreshHeatmap(); if (searchIsActive()) { @@ -4021,6 +4109,37 @@ zoomWatcher = { new URLSearchParams(location.search).get('search_scope') === 'area' ) ? 'area' : 'world'; + function refreshFacetCountsAfterSearchFlight(searchId) { + // A world-scope committed search auto-flies to the first result. That + // camera flight emits its own moveStart/moveEnd count refreshes, which + // can supersede the search refresh kicked off by applySearchFilterChange. + // Register this one-shot after the shared moveEnd listener exists; when + // the flight settles, this runs after the normal moveEnd refresh and + // becomes the final search-aware facet-count request for the committed + // search. + let remove = null; + let done = false; + const finish = () => { + if (done) return; + done = true; + if (remove) remove(); + setTimeout(() => { + if (searchId !== _searchSeq || !searchIsActive()) return; + window.a1dbg?.('search-flyto-final-facet-refresh', { + searchId, + token: window.__searchFilter?.token, + }); + refreshFacetCounts(); + }, 0); + }; + remove = viewer.camera.moveEnd.addEventListener(finish); + return () => { + if (done) return; + done = true; + if (remove) remove(); + }; + } + function persistSearchScope(scope) { // writeQueryState() doesn't know about scope; keep the URL param // honest by manipulating directly. 'world' is default, omitted from @@ -4233,6 +4352,47 @@ zoomWatcher = { } catch (e) { /* best effort */ } } + async function clearActiveSearchFilter() { + // Same clear path as an empty search submit, without entering doSearch(). + _searchSeq++; + if (searchInput) searchInput.value = ''; + const sidebarInput = document.getElementById('sampleSearchSidebar'); + if (sidebarInput) sidebarInput.value = ''; + if (searchResults) searchResults.textContent = ''; + await clearSearchFilter(); + await applySearchFilterChange(); + writeQueryState({ commitText: '' }); + } + + function renderDescribedByChip() { + const host = document.getElementById('activeSearchChipHost'); + if (!host) return; + + const sf = (typeof window !== 'undefined') ? window.__searchFilter : null; + const conceptActive = !!(sf && sf.active && sf.kind === 'concept'); + if (!conceptActive) { + host.hidden = true; + host.innerHTML = ''; + return; + } + + const label = sf.term || sf.uri || ''; + host.hidden = false; + host.innerHTML = `
+ Described by: ${escapeHtml(label)} + +
`; + + const clearBtn = host.querySelector('[data-clear-search-filter]'); + if (clearBtn) clearBtn.addEventListener('click', clearActiveSearchFilter); + } + + function syncSearchPanelState() { + renderDescribedByChip(); + const clusterEl = document.getElementById('clusterSection'); + if (clusterEl) clusterEl.hidden = searchIsActive(); + } + async function doSearch(scope) { if (scope === 'area' || scope === 'world') _searchScope = scope; const effectiveScope = _searchScope; @@ -4579,11 +4739,13 @@ zoomWatcher = { // (issue #207 item 8). User clicks on a specific row to // establish a new selection. if (effectiveScope === 'world' && results[0].latitude && results[0].longitude) { + 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), - duration: 1.5 + duration: 1.5, + cancel: cancelFinalFacetRefresh, }); }