diff --git a/explorer.qmd b/explorer.qmd index b249751..78fbcf3 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -17,9 +17,23 @@ format: --- +```{=html} +``` ::: {.callout-note collapse="true"} ## How It Works @@ -262,7 +302,13 @@ format: Circle size = log(sample count). Color = dominant data source. ::: - + +```{=html} +
@@ -270,11 +316,11 @@ Circle size = log(sample count). Color = dominant data source.
-
+
Searches labels, descriptions, and place names. First search can take 10-15 seconds while data loads; subsequent searches are faster. Tracking issue: faster substrate FTS in progress.
-
+
@@ -286,7 +332,10 @@ Searches labels, descriptions, and place names. First search can take 10
+
+``` +```{=html}
@@ -356,6 +405,7 @@ Loading H3 global overview...
+``` ```{ojs} //| output: false diff --git a/tests/playwright/explorer-layout-stability.spec.js b/tests/playwright/explorer-layout-stability.spec.js new file mode 100644 index 0000000..e80d4e2 --- /dev/null +++ b/tests/playwright/explorer-layout-stability.spec.js @@ -0,0 +1,188 @@ +const { test, expect } = require('@playwright/test'); + +const BASE_URL = process.env.TEST_URL || 'http://localhost:5860'; +const EXPLORER_PATH = '/explorer.html'; + +const ALT_WORLD = 10000000; +const ALT_POINT_CYPRUS = 62054; +const LAT_CYPRUS = 34.9954; +const LNG_CYPRUS = 33.7052; + +async function waitForPhaseMessage(page, substring, timeoutMs = 60000) { + return await page.waitForFunction( + (sub) => { + const el = document.getElementById('phaseMsg'); + const text = el ? el.textContent : ''; + return text.includes(sub) ? text.trim() : null; + }, + substring, + { timeout: timeoutMs } + ).then(handle => handle.jsonValue()); +} + +async function waitForClusterBoot(page) { + await waitForPhaseMessage(page, 'clusters,'); + // Awaiting `value('zoomWatcher')` ensures the OJS cell has finished + // running — listener registration + boot hydration are complete by the + // time this resolves. The cell returns the string "active" so we don't + // use the return value, only the await. + await page.evaluate(async () => { + return await window._ojs.ojsConnector.mainModule.value('zoomWatcher'); + }); +} + +async function waitForMode(page, expected, timeoutMs = 120000) { + await page.waitForFunction( + async (expectedMode) => { + const v = await window._ojs.ojsConnector.mainModule.value('viewer'); + return v && v._globeState && v._globeState.mode === expectedMode; + }, + expected, + { timeout: timeoutMs } + ); +} + +async function flyCameraTo(page, lat, lng, alt) { + await page.evaluate(async ({ lat, lng, alt }) => { + const v = await window._ojs.ojsConnector.mainModule.value('viewer'); + v.scene.requestRenderMode = false; + v.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(lng, lat, alt), + duration: 1.0, + }); + }, { lat, lng, alt }); +} + +async function settleLayout(page) { + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); +} + +async function elementRect(page, selector) { + await settleLayout(page); + return await page.locator(selector).evaluate((el) => { + const r = el.getBoundingClientRect(); + return { + top: r.top, + width: r.width, + height: r.height, + }; + }); +} + +function expectRectStable(actual, expected, tolerance = 2) { + expect(Math.abs(actual.top - expected.top)).toBeLessThanOrEqual(tolerance); + expect(Math.abs(actual.width - expected.width)).toBeLessThanOrEqual(tolerance); + expect(Math.abs(actual.height - expected.height)).toBeLessThanOrEqual(tolerance); +} + +/** Resolve `var(--explorer-map-height)` to a computed px value via a hidden + * probe element. Reading the custom property directly via + * `getPropertyValue('--explorer-map-height')` returns the unresolved + * `clamp(...)` string, not a px value. */ +async function resolveMapHeightPx(page) { + return await page.evaluate(() => { + const probe = document.createElement('div'); + probe.style.cssText = 'height: var(--explorer-map-height); position: absolute; visibility: hidden;'; + document.body.appendChild(probe); + const h = probe.getBoundingClientRect().height; + probe.remove(); + return h; + }); +} + +test.describe('explorer layout stability', () => { + test('desktop globe rect is stable across boot, status, point mode, and table round trip', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.waitForSelector('#cesiumContainer', { timeout: 30000 }); + + const initialRect = await elementRect(page, '#cesiumContainer'); + expect(initialRect.width).toBeGreaterThanOrEqual(840); + // At 1280×900 desktop, `clamp(500px, 65vh, 680px)` resolves to 585px + // (65vh = 585). Derive expected from the probe rather than hardcoding, + // for consistency with the mobile cases below. + const expectedDesktopMapHeight = await resolveMapHeightPx(page); + expect(expectedDesktopMapHeight).toBeGreaterThanOrEqual(500); + expect(expectedDesktopMapHeight).toBeLessThanOrEqual(680); + expect(Math.abs(initialRect.height - expectedDesktopMapHeight)).toBeLessThanOrEqual(2); + + await waitForClusterBoot(page); + expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect); + + await page.locator('#searchResults').evaluate((el) => { + el.textContent = '50+ results for a deliberately long search status that wraps across two reserved lines'; + }); + expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect); + + await flyCameraTo(page, LAT_CYPRUS, LNG_CYPRUS, ALT_POINT_CYPRUS); + await waitForMode(page, 'point'); + // Wait on the trailing phrase common to BOTH point-mode done branches + // (normal: " individual samples. Click one for details." and cap-reached: + // " samples in view (showing M — zoom in for more). Click one for details.") + // rather than "individual samples", which misses the cap-reached path. + await waitForPhaseMessage(page, 'Click one for details', 120000); + expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect); + + await page.locator('#tableViewBtn').click(); + await expect(page.locator('.globe-layout')).toBeHidden(); + await expect(page.locator('#tableContainer')).toBeVisible(); + const tableRect = await elementRect(page, '#tableContainer'); + expect(Math.abs(tableRect.top - initialRect.top)).toBeLessThanOrEqual(2); + + await page.locator('#globeViewBtn').click(); + await expect(page.locator('.globe-layout')).toBeVisible(); + expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect); + }); + + test('mobile globe height override is stable across boot and wrapped status', async ({ page }) => { + const viewport = { width: 390, height: 844 }; + await page.setViewportSize(viewport); + await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.waitForSelector('#cesiumContainer', { timeout: 30000 }); + + const initialRect = await elementRect(page, '#cesiumContainer'); + // At 390×844, mobile CSS resolves `clamp(360px, 58vh, 520px)` to 489.52px + // (58vh of 844). Derive expected via the probe helper. + const expectedMapHeight = await resolveMapHeightPx(page); + // Sanity: must be within the mobile clamp bounds [360, 520]. + expect(expectedMapHeight).toBeGreaterThanOrEqual(360); + expect(expectedMapHeight).toBeLessThanOrEqual(520); + expect(Math.abs(initialRect.height - expectedMapHeight)).toBeLessThanOrEqual(2); + + await waitForClusterBoot(page); + expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect); + + await page.locator('#searchResults').evaluate((el) => { + el.textContent = 'Search error: a long mobile status message that should scroll inside its reserved slot'; + }); + expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect); + }); + + test('small-phone (320×568) clamps map height to the 360px floor', async ({ page }) => { + // At 320×568, mobile CSS resolves `clamp(360px, 58vh, 520px)` with + // 58vh = 329.44px — below the 360px floor — so map height = 360px. + // Covers the clamp-floor branch which the 390×844 case never exercises. + await page.setViewportSize({ width: 320, height: 568 }); + await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=${ALT_WORLD}`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.waitForSelector('#cesiumContainer', { timeout: 30000 }); + + const initialRect = await elementRect(page, '#cesiumContainer'); + const expectedMapHeight = await resolveMapHeightPx(page); + expect(expectedMapHeight).toBe(360); + expect(Math.abs(initialRect.height - 360)).toBeLessThanOrEqual(2); + + await waitForClusterBoot(page); + expectRectStable(await elementRect(page, '#cesiumContainer'), initialRect); + }); +});