diff --git a/explorer.qmd b/explorer.qmd index 6448f77..cf13394 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -3484,19 +3484,48 @@ zoomWatcher = { }, 250); } - document.getElementById('heatmapToggle')?.addEventListener('change', () => { + document.getElementById('heatmapToggle')?.addEventListener('change', async () => { if (heatmapEnabled()) { refreshHeatmap(); + // #233 phase 3: heatmap is mutually exclusive with the marker + // layers, so flip dot visibility and re-evaluate the facet apology + // note (which is meaningless once the heatmap shows filtered + // density directly). Both helpers read live state, so order vs the + // refresh/clear above does not matter. + applyLayerVisibility(); + syncFacetNote(); } else { + // #262 fix: heatmap-OFF must reload the altitude-appropriate marker + // layer, not just flip visibility. If the session lived entirely in + // heatmap mode the marker layer was never (re)loaded for the current + // view; toggling .show = true without data leaves an empty globe. + // Nulling currentRes defeats the "same-res skip" guard in the camera + // handler AND in the inline loadRes call below so the reload always + // fires even if the target resolution equals the stale stored value. clearHeatmap(); + applyLayerVisibility(); + syncFacetNote(); + busyAcquire(); + try { + currentRes = null; // defeat same-res skip guard (#262) + const h = viewer.camera.positionCartographic.height; + if (searchIsActive() || h < ENTER_POINT_ALT) { + // Search active: clusters cannot be text-filtered, so stay/ + // enter point mode. Low altitude: enter point mode normally. + await enterPointMode(false); + } else { + // Cluster altitude: load the altitude-appropriate resolution. + const target = h > 3000000 ? 4 : h > 300000 ? 6 : 8; + const applied = await loadRes(target, { 4: h3_res4_url, 6: h3_res6_url, 8: h3_res8_url }[target]); + // Chase: user may have zoomed inside ENTER_POINT_ALT while + // the cluster load was in flight (mirrors the camera handler + // pattern from issue #193). + if (applied) await tryEnterPointModeIfNeeded(); + } + } finally { + busyRelease(); + } } - // #233 phase 3: heatmap is mutually exclusive with the marker - // layers, so flip dot visibility and re-evaluate the facet apology - // note (which is meaningless once the heatmap shows filtered - // density directly). Both helpers read live state, so order vs the - // refresh/clear above does not matter. - applyLayerVisibility(); - syncFacetNote(); }); viewer._heatmapOverlay = { enabled: false, layer: null, lastRefreshAt: 0, lastPointCount: 0, lastKey: null }; viewer._heatmapSkips = 0; // tolerance-dedupe skip counter (test signal) @@ -4230,6 +4259,7 @@ zoomWatcher = { samplePointsLen: viewer.samplePoints.length, samplePointsShown: viewer.samplePoints.show, h3PointsShown: viewer.h3Points.show, + h3PointsLen: viewer.h3Points.length, // #262: needed to assert clusters loaded }; } catch (e) { return { error: String(e) }; } }; diff --git a/tests/playwright/heatmap-off-restore.mjs b/tests/playwright/heatmap-off-restore.mjs new file mode 100644 index 0000000..1bfd6bc --- /dev/null +++ b/tests/playwright/heatmap-off-restore.mjs @@ -0,0 +1,332 @@ +// heatmap-off-restore.mjs — #262 mode-matrix regression harness +// +// Verifies that toggling heatmap OFF correctly (re)loads the altitude- +// appropriate marker layer. Mirrors a1-verify.mjs: plain `chromium`, +// run with `node tests/playwright/heatmap-off-restore.mjs`. NOT a +// .spec.js so it stays out of the default Playwright suite. +// +// Pre-requisites (same as a1-verify.mjs): +// 1. Build: quarto render explorer.qmd +// 2. Serve: python3 dev_server.py --dir docs --port 8076 +// 3. Run: node tests/playwright/heatmap-off-restore.mjs +// +// Set HEADLESS=1 for CI. Set PORT=xxxx to override default 8076. + +import { chromium } from 'playwright'; + +const PORT = process.env.PORT || '8076'; +const BASE = `http://localhost:${PORT}/explorer.html?data_base=/data&debug=a1`; +const HEADLESS = process.env.HEADLESS === '1'; + +// Cluster altitude — well above ENTER_POINT_ALT (120 km) +const CLUSTER_ALT = 9_000_000; // 9 000 km → res4 +// Point altitude — well below ENTER_POINT_ALT +const POINT_ALT = 8_000; // 8 km + +const TIMEOUT_INIT = 180_000; // cold DuckDB-WASM boot +const TIMEOUT_LOAD = 90_000; // cluster / point data load +const TIMEOUT_TOGGLE = 60_000; // heatmap toggle → data settle + +const browser = await chromium.launch({ headless: HEADLESS }); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeUrl(lat, lng, alt, extra = '') { + return `${BASE}&${extra}#v=1&lat=${lat}&lng=${lng}&alt=${alt}`; +} + +async function waitAppLive(page, url) { + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => typeof window.a1dbg === 'function' && + typeof window.__a1globe === 'function' && + !!document.querySelector('#heatmapToggle'), + null, { timeout: TIMEOUT_INIT }); +} + +async function globe(page) { + return page.evaluate(() => window.__a1globe?.()); +} + +async function waitFor(page, predFn, timeout, label) { + try { + await page.waitForFunction(predFn, null, { timeout }); + } catch (_) { + const g = await globe(page); + console.error(` TIMEOUT waiting for: ${label}`); + console.error(` globe state: ${JSON.stringify(g)}`); + throw new Error(`Timeout: ${label}`); + } +} + +async function heatmapOn(page) { + // Toggle heatmap on (if not already). + const on = await page.evaluate(() => document.getElementById('heatmapToggle')?.checked); + if (!on) { + await page.click('#heatmapToggle'); + } + // Wait for the heatmap to be reflected in __a1globe (h3Points hidden). + await waitFor(page, () => window.__a1globe?.()?.h3PointsShown === false, TIMEOUT_TOGGLE, 'heatmap ON → h3Points hidden'); +} + +async function heatmapOff(page) { + // Toggle heatmap off (if not already). + const on = await page.evaluate(() => document.getElementById('heatmapToggle')?.checked); + if (on) { + await page.click('#heatmapToggle'); + } +} + +let passed = 0; +let failed = 0; +const failures = []; + +function assert(cond, msg, ctx) { + if (cond) { + console.log(` ✓ ${msg}`); + passed++; + } else { + console.error(` ✗ ${msg} (${JSON.stringify(ctx)})`); + failed++; + failures.push(msg); + } +} + +// ─── Test 1: boot with heatmap=1 at cluster altitude ───────────────────────── +// Assert heatmap visible, markers hidden. +{ + console.log('\n[T1] Boot with heatmap=1 at cluster altitude → assert heatmap shown, markers hidden'); + const page = await browser.newPage(); + try { + const url = makeUrl(43.15, 11.40, CLUSTER_ALT, 'heatmap=1&sources=OPENCONTEXT'); + await waitAppLive(page, url); + + // Phase 1 should have loaded; give heatmap a moment to render. + await waitFor(page, + () => document.getElementById('heatmapToggle')?.checked === true, + TIMEOUT_TOGGLE, 'T1: heatmap toggle checked'); + + const g = await globe(page); + assert(g?.h3PointsShown === false, 'T1: h3Points hidden while heatmap on', g); + assert(g?.samplePointsShown === false, 'T1: samplePoints hidden while heatmap on', g); + assert(g?.mode === 'cluster', 'T1: mode is cluster at cluster altitude', g); + console.log(` globe: ${JSON.stringify(g)}`); + } finally { + await page.close(); + } +} + +// ─── Test 2: THE BUG — boot heatmap=1 at cluster alt, toggle OFF → clusters must load ─ +{ + console.log('\n[T2] Boot heatmap=1 at cluster altitude, toggle OFF → H3 clusters must load (#262 regression)'); + const page = await browser.newPage(); + try { + const url = makeUrl(43.15, 11.40, CLUSTER_ALT, 'heatmap=1&sources=OPENCONTEXT'); + await waitAppLive(page, url); + + // Wait for toggle to be checked (boot hydration). + await waitFor(page, + () => document.getElementById('heatmapToggle')?.checked === true, + TIMEOUT_TOGGLE, 'T2: heatmap toggle checked at boot'); + + // Turn heatmap OFF. + await heatmapOff(page); + + // Wait for clusters to load and be visible. + await waitFor(page, + () => { + const g = window.__a1globe?.(); + return g?.mode === 'cluster' && + g?.h3PointsShown === true && + (g?.h3PointsLen ?? 0) > 0; + }, + TIMEOUT_LOAD, 'T2: h3Points loaded and shown after heatmap OFF'); + + const g = await globe(page); + assert(g?.mode === 'cluster', 'T2: mode remains cluster', g); + assert(g?.h3PointsShown === true, 'T2: h3Points shown', g); + assert((g?.h3PointsLen ?? 0) > 0, `T2: h3Points non-empty (got ${g?.h3PointsLen})`, g); + assert(g?.samplePointsShown === false, 'T2: samplePoints still hidden', g); + console.log(` globe: ${JSON.stringify(g)}`); + } finally { + await page.close(); + } +} + +// ─── Test 3: heatmap OFF at point altitude → sample points load ─────────────── +{ + console.log('\n[T3] Heatmap ON at point altitude, toggle OFF → sample points load'); + const page = await browser.newPage(); + try { + // Boot at cluster alt so we get a stable cluster-mode base; heatmap on. + const url = makeUrl(35.0, 33.0, POINT_ALT, 'heatmap=1&sources=OPENCONTEXT'); + await waitAppLive(page, url); + + // At low altitude the app may boot directly into point mode. + // Wait for heatmap to be checked (boot hydration) AND for the app to settle. + await waitFor(page, + () => document.getElementById('heatmapToggle')?.checked === true, + TIMEOUT_TOGGLE, 'T3: heatmap toggle checked at boot'); + + // Turn heatmap OFF at low altitude. + await heatmapOff(page); + + // Should enter point mode with sample points loaded. + await waitFor(page, + () => { + const g = window.__a1globe?.(); + return g?.mode === 'point' && + g?.samplePointsShown === true && + (g?.samplePointsLen ?? 0) > 0; + }, + TIMEOUT_LOAD, 'T3: sample points loaded after heatmap OFF at low alt'); + + const g = await globe(page); + assert(g?.mode === 'point', 'T3: mode is point at low altitude', g); + assert(g?.samplePointsShown === true, 'T3: samplePoints shown', g); + assert((g?.samplePointsLen ?? 0) > 0, `T3: samplePoints non-empty (got ${g?.samplePointsLen})`, g); + assert(g?.h3PointsShown === false, 'T3: h3Points hidden in point mode', g); + console.log(` globe: ${JSON.stringify(g)}`); + } finally { + await page.close(); + } +} + +// ─── Test 4: heatmap OFF with search active → filtered point markers ────────── +{ + console.log('\n[T4] Heatmap ON with search active, toggle OFF → filtered point markers'); + const page = await browser.newPage(); + try { + // Boot at cluster altitude, no heatmap yet. + const url = makeUrl(43.15, 11.40, CLUSTER_ALT, 'sources=OPENCONTEXT'); + await waitAppLive(page, url); + + // Commit a search to force point mode. + const TERM = 'bucchero'; + await page.fill('#sampleSearch', TERM); + await page.press('#sampleSearch', 'Enter'); + // Use page.waitForFunction directly here so we can pass TERM as the arg. + await page.waitForFunction( + (t) => window.__searchFilter?.active === true && + window.__searchFilter?.term === t && + window.__searchFilter?.total > 0, + TERM, { timeout: TIMEOUT_LOAD }); + + // Wait for point mode with filtered sample points. + await waitFor(page, + () => { + const g = window.__a1globe?.(); + return g?.mode === 'point' && (g?.samplePointsLen ?? 0) > 0; + }, + TIMEOUT_LOAD, 'T4: point mode with samples after search'); + + // Now enable heatmap. + await heatmapOn(page); + + // With heatmap on and search active, markers should be hidden. + const gHeat = await globe(page); + assert(gHeat?.samplePointsShown === false, 'T4: samplePoints hidden while heatmap+search on', gHeat); + + // Toggle heatmap off — search is still active, must stay in point mode with filtered points. + await heatmapOff(page); + + await waitFor(page, + () => { + const g = window.__a1globe?.(); + return g?.mode === 'point' && + g?.samplePointsShown === true && + (g?.samplePointsLen ?? 0) > 0; + }, + TIMEOUT_LOAD, 'T4: filtered point markers after heatmap OFF + active search'); + + const g = await globe(page); + assert(g?.mode === 'point', 'T4: still in point mode (search forces it)', g); + assert(g?.samplePointsShown === true, 'T4: samplePoints shown', g); + assert((g?.samplePointsLen ?? 0) > 0, `T4: samplePoints non-empty (got ${g?.samplePointsLen})`, g); + console.log(` globe: ${JSON.stringify(g)}`); + } finally { + await page.close(); + } +} + +// ─── Test 5: normal cluster→point transition (no heatmap) still works ───────── +{ + console.log('\n[T5] Normal (no-heatmap) cluster boot → heatmap ON → OFF → clusters still show'); + const page = await browser.newPage(); + try { + const url = makeUrl(43.15, 11.40, CLUSTER_ALT, 'sources=OPENCONTEXT'); + await waitAppLive(page, url); + + // Wait for phase1 cluster data to load. + await waitFor(page, + () => { + const g = window.__a1globe?.(); + return g?.mode === 'cluster' && + g?.h3PointsShown === true && + (g?.h3PointsLen ?? 0) > 0; + }, + TIMEOUT_LOAD, 'T5: initial cluster load'); + + const gBefore = await globe(page); + assert(gBefore?.h3PointsShown === true, 'T5: clusters shown before heatmap', gBefore); + + // Heatmap on → hides clusters. + await heatmapOn(page); + const gHeat = await globe(page); + assert(gHeat?.h3PointsShown === false, 'T5: clusters hidden while heatmap on', gHeat); + + // Heatmap off → clusters must come back. + await heatmapOff(page); + await waitFor(page, + () => { + const g = window.__a1globe?.(); + return g?.h3PointsShown === true && (g?.h3PointsLen ?? 0) > 0; + }, + TIMEOUT_LOAD, 'T5: clusters restored after heatmap OFF'); + + const gAfter = await globe(page); + assert(gAfter?.h3PointsShown === true, 'T5: clusters shown after heatmap off', gAfter); + assert((gAfter?.h3PointsLen ?? 0) > 0, `T5: h3PointsLen > 0 (got ${gAfter?.h3PointsLen})`, gAfter); + console.log(` globe: ${JSON.stringify(gAfter)}`); + } finally { + await page.close(); + } +} + +// ─── Test 6: heatmap ON still suppresses markers ───────────────────────────── +{ + console.log('\n[T6] Enabling heatmap suppresses h3Points (regression: heatmap ON must still hide markers)'); + const page = await browser.newPage(); + try { + const url = makeUrl(43.15, 11.40, CLUSTER_ALT, 'sources=OPENCONTEXT'); + await waitAppLive(page, url); + + // Wait for clusters to show. + await waitFor(page, + () => (window.__a1globe?.()?.h3PointsLen ?? 0) > 0, + TIMEOUT_LOAD, 'T6: initial cluster load'); + + await heatmapOn(page); + + const g = await globe(page); + assert(g?.h3PointsShown === false, 'T6: h3Points hidden after heatmap ON', g); + assert(g?.samplePointsShown === false, 'T6: samplePoints hidden after heatmap ON', g); + console.log(` globe: ${JSON.stringify(g)}`); + } finally { + await page.close(); + } +} + +// ─── summary ────────────────────────────────────────────────────────────────── + +await browser.close(); + +console.log(`\n${'─'.repeat(60)}`); +console.log(`Results: ${passed} passed, ${failed} failed`); +if (failures.length) { + console.error('Failures:'); + failures.forEach(f => console.error(` - ${f}`)); + process.exit(1); +} else { + console.log('All assertions passed.'); +}