diff --git a/explorer.qmd b/explorer.qmd index 6448f77..1dbe820 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -3644,8 +3644,32 @@ zoomWatcher = { syncFacetNote(); writeQueryState(); refreshHeatmap(); - if (getMode() === 'point') { - await loadViewportSamples(); + // #267: facet selection must visibly drive the MAP, not just the + // table/legend. Cluster dots come from pre-aggregated H3 summaries + // that carry only `dominant_source`, so they cannot honor a + // material/context/object_type facet. Mirror the committed-search + // path (applySearchFilterChange / C3): when any such facet is + // active, FORCE point mode so the map shows the actual filtered + // dots (which already apply facetFilterSQL()). When facets are + // cleared, revert to the altitude-appropriate mode — unless a text + // search is still latching point mode. + if (hasFacetFilters()) { + if (getMode() !== 'point') { + await enterPointMode(false); // forces point; awaits filtered viewport load + } else { + await loadViewportSamples(); + } + } else if (getMode() === 'point') { + const h = viewer.camera.positionCartographic.height; + if (!searchIsActive() && h >= EXIT_POINT_ALT) { + exitPointMode(false); + const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8; + if (target !== currentRes) { + await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]); + } + } else { + await loadViewportSamples(); + } } refreshFacetCounts(); await new Promise(r => setTimeout(r, 300)); @@ -3675,14 +3699,21 @@ zoomWatcher = { syncSearchPanelState(); syncFacetNote(); refreshHeatmap(); - if (searchIsActive()) { + // #267: point mode is latched by EITHER an active search OR an + // active facet — both filter the sample set and can't be shown as + // clusters. So clearing the search must NOT revert to clusters if a + // facet is still checked (and vice-versa, handled in + // handleFacetFilterChange). Use the same forced-point predicate as + // every other latch. + const forcePoint = searchIsActive() || hasFacetFilters(); + if (forcePoint) { if (getMode() !== 'point') { await enterPointMode(false); // forces point; awaits filtered viewport load } else { await loadViewportSamples(); } } else { - // Search cleared: revert to the altitude-appropriate mode. + // Neither search nor facet active: revert to altitude-appropriate mode. const h = viewer.camera.positionCartographic.height; if (getMode() === 'point' && h >= EXIT_POINT_ALT) { exitPointMode(false); @@ -3727,16 +3758,17 @@ zoomWatcher = { // A1 (#234 Step 4) / C3: while a search is active, latch point // mode regardless of altitude — clusters can't be text-filtered, // so we keep showing the filtered sample dots even when zoomed out. - const targetMode = searchIsActive() ? 'point' + const targetMode = (searchIsActive() || hasFacetFilters()) ? 'point' : h < ENTER_POINT_ALT ? 'point' : h > EXIT_POINT_ALT ? 'cluster' : getMode(); if (targetMode === 'point' && getMode() !== 'point') { - if (searchIsActive()) { - // Search forces point mode even above ENTER_POINT_ALT, - // where tryEnterPointModeIfNeeded() would refuse; enter - // directly so the filtered dots render at any zoom. + if (searchIsActive() || hasFacetFilters()) { + // Search or an active facet (#267) forces point mode even + // above ENTER_POINT_ALT, where tryEnterPointModeIfNeeded() + // would refuse; enter directly so the filtered dots render + // at any zoom. await enterPointMode(false); } else { // Cold-cache deep-link: the res8 + samples_map_lite fetches @@ -3877,7 +3909,7 @@ zoomWatcher = { // moveEnd must NOT exit to clusters — otherwise the post-search // flyTo (200 km, above EXIT_POINT_ALT) would immediately undo the // forced point mode and the globe would show unfiltered clusters. - if (h > EXIT_POINT_ALT && !searchIsActive()) { + if (h > EXIT_POINT_ALT && !searchIsActive() && !hasFacetFilters()) { // Sub-10% zoom-out from point mode (e.g. 175 km → 181 km) won't // fire `camera.changed`, so without driving the exit here we'd // be stuck in point mode above `EXIT_POINT_ALT` until a larger @@ -4017,7 +4049,7 @@ zoomWatcher = { // A1 (#234 Step 4): an active search forces point mode regardless // of the restored altitude, so the back/forward globe state stays // coherent with the (still-filtered) table/legend. - const wantsPoint = searchIsActive() || s.mode === 'point' || (s.alt != null && s.alt < ENTER_POINT_ALT); + const wantsPoint = searchIsActive() || hasFacetFilters() || s.mode === 'point' || (s.alt != null && s.alt < ENTER_POINT_ALT); if (wantsPoint && getMode() !== 'point') await enterPointMode(false); else if (!wantsPoint && getMode() === 'point') exitPointMode(false); }, 2000); @@ -5266,6 +5298,21 @@ zoomWatcher = { await tryEnterPointModeIfNeeded({ pushHistory: false }); } + // #267: a shared `?material=`/`?context=`/`?object_type=` deep link must + // force point mode like `?search=` does — cluster H3 summaries can't honor + // those facets. The altitude-gated trigger above won't promote a high-alt + // facet deep link, so enter point mode directly. We read the URL params + // (not the hydrated checkboxes) so this is independent of facet-loader + // timing; the latch points (targetMode / moveEnd / wantsPoint) keep it + // pinned once the checkboxes hydrate. + const _urlHasFacets = ['material', 'context', 'object_type'].some(p => { + const v = new URLSearchParams(location.search).get(p); + return v != null && v.trim() !== ''; + }); + if (_urlHasFacets && getMode() !== 'point') { + await enterPointMode(false); + } + // #233 phase 1: hydrate heatmap overlay from `heatmap=1` URL param. // Reported by RY 2026-05-27 on PR #240 staging — toggle state was // missing from "Copy Link to Current View." `refreshHeatmap()` lives