From cbc92bab9deed4e64394ba349745b3b31178458e Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Wed, 13 May 2026 14:26:04 -0700 Subject: [PATCH 01/11] explorer: move search controls into in-map overlay (M-1A) Relocate the search input, scope buttons (Search Selected Areas / Search Entire World), help text, and results count from the top-of-page .explorer-controls block into an absolutely-positioned .map-search-overlay inside a new .map-wrap div around #cesiumContainer. All element IDs preserved (#sampleSearch, #searchAreaBtn, #searchWorldBtn, #searchResults) so existing handlers in the zoomWatcher OJS cell and the URL state contract continue to work unchanged. Part of #200 mockup-alignment work. The Globe/Table toggle and #tableControls stay in the top-level .explorer-controls block for now; they will be removed in a later commit when the table becomes permanent. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 55 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index 78fbcf3..25bad4b 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -55,12 +55,38 @@ format: } .globe-layout { grid-template-columns: 1fr; } } + .map-wrap { + position: relative; + width: 100%; + } #cesiumContainer { width: 100%; height: var(--explorer-map-height); min-height: 0; aspect-ratio: auto; } + .map-search-overlay { + position: absolute; + top: 8px; + left: 50px; /* clear of Cesium toolbar column at left: 5px */ + width: min(480px, calc(100% - 60px)); + z-index: 1000; + background: rgba(255, 255, 255, 0.94); + backdrop-filter: blur(4px); + padding: 8px 10px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + gap: 6px; + } + .map-search-overlay .search-results { color: #333; } + @media (max-width: 900px) { + .map-search-overlay { + left: 8px; + width: calc(100% - 16px); + } + } .side-panel { display: flex; flex-direction: column; @@ -309,19 +335,6 @@ Circle size = log(sample count). Color = dominant data source. and adding paragraph margins to control rows. --> ```{=html}
- -
- - -
-
-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. -
-
-
@@ -337,7 +350,23 @@ Searches labels, descriptions, and place names. First search can take 10 ```{=html}
+
+
+ +
+ + +
+
+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. +
+
+
+
From 17bb83636f828d43bc2f90851dd441d09b443b8a Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Wed, 13 May 2026 14:48:18 -0700 Subject: [PATCH 02/11] explorer: add sidebar open-text search input (M-1B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add #sampleSearchSidebar input to the top .panel-section of .side-panel. Two-way input-event mirror with the in-map #sampleSearch keeps the two input chrome in sync as a single logical query term. Enter on the sidebar input always submits world scope per the Option B decision in the #200 mockup discussion — a typed-text query from the sidebar implies "find anywhere" rather than "limit to current map view." applyQueryToSearch() now hydrates both inputs from the ?search= URL param. writeQueryState() still reads from #sampleSearch only since the mirror guarantees value parity. The mirror guards against feedback loops by comparing values before setting; programmatic .value assignment does not fire 'input', so a strict-equality guard is sufficient (no debounce or recursion flag). Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index 25bad4b..da1c7b3 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -87,6 +87,17 @@ format: width: calc(100% - 16px); } } + .sidebar-search { display: flex; flex-direction: column; gap: 4px; } + .sidebar-search input { + width: 100%; + padding: 7px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 13px; + outline: none; + } + .sidebar-search input:focus { border-color: #1565c0; box-shadow: 0 0 0 2px rgba(21,101,192,0.15); } + .sidebar-search-hint { font-size: 11px; color: #888; } .side-panel { display: flex; flex-direction: column; @@ -368,6 +379,10 @@ Searches labels, descriptions, and place names. First search can take 10
+
Loading...Resolution
@@ -548,10 +563,14 @@ function applyQueryToSourceFilter() { // See docs/site_libs/quarto-search/quarto-search.js. function applyQueryToSearch() { const input = document.getElementById('sampleSearch'); - if (!input) return; + const sidebarInput = document.getElementById('sampleSearchSidebar'); + if (!input && !sidebarInput) return; const params = new URLSearchParams(location.search); const q = params.get('search'); - if (q != null) input.value = q; + if (q != null) { + if (input) input.value = q; + if (sidebarInput) sidebarInput.value = q; + } } function setCheckedValues(containerId, values) { @@ -2749,6 +2768,28 @@ zoomWatcher = { if (e.key === 'Enter') doSearch(_searchScope); }); + // Sidebar open-text search (M-1B). Mirrors #sampleSearch so there's one + // logical query term with two input chrome. Enter on sidebar always + // submits world scope — typed-text-from-sidebar implies "find anywhere". + // The mirror guards against feedback loops by comparing values before + // setting; setting .value does not fire 'input' so a simple guard suffices. + const searchInputSidebar = document.getElementById('sampleSearchSidebar'); + if (searchInputSidebar && searchInput) { + searchInputSidebar.addEventListener('input', () => { + if (searchInput.value !== searchInputSidebar.value) { + searchInput.value = searchInputSidebar.value; + } + }); + searchInput.addEventListener('input', () => { + if (searchInputSidebar.value !== searchInput.value) { + searchInputSidebar.value = searchInput.value; + } + }); + } + if (searchInputSidebar) searchInputSidebar.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doSearch('world'); + }); + if (searchInput && searchInput.value.trim().length >= 2) { doSearch(_searchScope); } From af99329e217a17cb92864d5c0a1645c9d23c6e90 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Wed, 13 May 2026 14:54:48 -0700 Subject: [PATCH 03/11] explorer: address Codex round-1 review on map overlay (M-1A/M-1B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged three blockers on the cumulative M-1A + M-1B diff: 1. Mobile overlay collided with the Cesium toolbar. At max-width: 900px the overlay was switching to left: 8px, covering the toolbar column at left: 5px. Removed the mobile left-shift; keep the overlay offset at left: 50px so the vertical toolbar remains hit-targetable. Width adjusts to calc(100% - 58px) instead. 2. Base-layer picker dropdown could be occluded by the overlay (dropdown opens at left: 36px; overlay starts at left: 50px). Bump .cesium-baseLayerPicker-dropDown z-index to 1100 so it wins the z-stack against the overlay's 1000. 3. No automated coverage for overlay-vs-toolbar collision. Add tests/playwright/explorer-map-overlay.spec.js with four specs: desktop overlap, mobile overlap, dropdown z-index ordering, and a smoke test that the sidebar↔in-map input mirror is bidirectional. Also picked up the small IME concern from Codex ask #1: gate both Enter handlers (in-map and sidebar) on `!e.isComposing && e.keyCode !== 229` so IMEs that emit Enter to commit a candidate don't trigger a search on the pre-commit value. Non-blocker items (URL-clears-search edge case, aria-label on #sampleSearch, aria-describedby for hints) deferred — to be picked up in the EXPLORER_STATE.md / a11y commit at the end of the PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 21 +++- tests/playwright/explorer-map-overlay.spec.js | 104 ++++++++++++++++++ 2 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 tests/playwright/explorer-map-overlay.spec.js diff --git a/explorer.qmd b/explorer.qmd index da1c7b3..9751d84 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -82,9 +82,11 @@ format: } .map-search-overlay .search-results { color: #333; } @media (max-width: 900px) { + /* Keep the overlay clear of the vertical Cesium toolbar column at + left: 5px. Don't switch to left: 8px here — that overlaps the + toolbar hit targets at narrow widths. */ .map-search-overlay { - left: 8px; - width: calc(100% - 16px); + width: calc(100% - 58px); } } .sidebar-search { display: flex; flex-direction: column; gap: 4px; } @@ -162,10 +164,13 @@ format: align-items: flex-start; } .cesium-viewer-toolbar > * { margin-left: 0 !important; } - /* Open baseLayerPicker dropdown to the right since we're left-anchored */ + /* Open baseLayerPicker dropdown to the right since we're left-anchored. + z-index must beat .map-search-overlay (1000) so the dropdown is not + occluded by the in-map search controls. */ .cesium-baseLayerPicker-dropDown { left: 36px; right: auto; + z-index: 1100; } .explorer-controls { display: flex; @@ -2765,7 +2770,11 @@ zoomWatcher = { // no button has been clicked yet). Defaults to 'world' for keyboard-only // users on first invocation. if (searchInput) searchInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') doSearch(_searchScope); + // Skip Enter during IME composition — IMEs send Enter to commit a + // candidate, and submitting then would fire on the pre-commit value. + if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { + doSearch(_searchScope); + } }); // Sidebar open-text search (M-1B). Mirrors #sampleSearch so there's one @@ -2787,7 +2796,9 @@ zoomWatcher = { }); } if (searchInputSidebar) searchInputSidebar.addEventListener('keydown', (e) => { - if (e.key === 'Enter') doSearch('world'); + if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { + doSearch('world'); + } }); if (searchInput && searchInput.value.trim().length >= 2) { diff --git a/tests/playwright/explorer-map-overlay.spec.js b/tests/playwright/explorer-map-overlay.spec.js new file mode 100644 index 0000000..5b67c84 --- /dev/null +++ b/tests/playwright/explorer-map-overlay.spec.js @@ -0,0 +1,104 @@ +const { test, expect } = require('@playwright/test'); + +const BASE_URL = process.env.TEST_URL || 'http://localhost:5860'; +const EXPLORER_PATH = '/explorer.html'; + +async function getRect(page, selector) { + return await page.locator(selector).first().evaluate((el) => { + const r = el.getBoundingClientRect(); + return { top: r.top, left: r.left, right: r.right, bottom: r.bottom, width: r.width, height: r.height }; + }); +} + +function rectsOverlap(a, b) { + return !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top); +} + +test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', () => { + test('desktop: overlay does not cover Cesium toolbar buttons', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.waitForSelector('#cesiumContainer', { timeout: 30000 }); + await page.waitForSelector('.cesium-viewer-toolbar', { timeout: 30000 }); + await page.waitForSelector('.map-search-overlay', { timeout: 10000 }); + + const overlay = await getRect(page, '.map-search-overlay'); + const toolbar = await getRect(page, '.cesium-viewer-toolbar'); + expect(rectsOverlap(overlay, toolbar), `overlay ${JSON.stringify(overlay)} overlaps toolbar ${JSON.stringify(toolbar)}`).toBeFalsy(); + + // Each individual toolbar child button should also be unobstructed. + const buttonCount = await page.locator('.cesium-viewer-toolbar > *').count(); + expect(buttonCount).toBeGreaterThan(0); + for (let i = 0; i < buttonCount; i++) { + const btn = await page.locator('.cesium-viewer-toolbar > *').nth(i).evaluate((el) => { + const r = el.getBoundingClientRect(); + return { top: r.top, left: r.left, right: r.right, bottom: r.bottom }; + }); + expect(rectsOverlap(overlay, btn), `overlay obstructs toolbar button #${i} (${JSON.stringify(btn)})`).toBeFalsy(); + } + }); + + test('mobile (390px): overlay does not cover Cesium toolbar', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.waitForSelector('#cesiumContainer', { timeout: 30000 }); + await page.waitForSelector('.cesium-viewer-toolbar', { timeout: 30000 }); + await page.waitForSelector('.map-search-overlay', { timeout: 10000 }); + + const overlay = await getRect(page, '.map-search-overlay'); + const toolbar = await getRect(page, '.cesium-viewer-toolbar'); + expect(rectsOverlap(overlay, toolbar), `mobile overlay ${JSON.stringify(overlay)} overlaps toolbar ${JSON.stringify(toolbar)}`).toBeFalsy(); + }); + + test('base-layer picker dropdown opens above the search overlay', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.waitForSelector('.cesium-baseLayerPicker-selected', { timeout: 30000 }); + await page.waitForSelector('.map-search-overlay', { timeout: 10000 }); + + await page.locator('.cesium-baseLayerPicker-selected').click(); + const dropdown = page.locator('.cesium-baseLayerPicker-dropDown').first(); + await expect(dropdown).toBeVisible({ timeout: 5000 }); + + // The dropdown geometrically overlaps the overlay area; what matters is + // that the dropdown wins the z-stack so its options are clickable. + // Resolve effective z-index of both via getComputedStyle. + const zCompare = await page.evaluate(() => { + const dd = document.querySelector('.cesium-baseLayerPicker-dropDown'); + const ov = document.querySelector('.map-search-overlay'); + const z = (el) => parseInt(getComputedStyle(el).zIndex, 10); + return { dd: z(dd), ov: z(ov) }; + }); + expect(zCompare.dd, `base-layer-picker dropdown z-index (${zCompare.dd}) must beat overlay (${zCompare.ov})`).toBeGreaterThan(zCompare.ov); + }); + + test('sidebar search input mirrors in-map search input', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto(`${BASE_URL}${EXPLORER_PATH}`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.waitForSelector('#sampleSearch', { timeout: 30000 }); + await page.waitForSelector('#sampleSearchSidebar', { timeout: 10000 }); + + // The mirror is wired in the zoomWatcher OJS cell — wait for it to be ready. + await page.evaluate(async () => { + return await window._ojs.ojsConnector.mainModule.value('zoomWatcher'); + }); + + await page.locator('#sampleSearchSidebar').fill('pottery'); + await expect(page.locator('#sampleSearch')).toHaveValue('pottery'); + + await page.locator('#sampleSearch').fill('basalt'); + await expect(page.locator('#sampleSearchSidebar')).toHaveValue('basalt'); + }); +}); From c943fe1945704fe6f8415efd67abc92e29342f63 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Wed, 13 May 2026 15:02:00 -0700 Subject: [PATCH 04/11] =?UTF-8?q?explorer:=20Codex=20round-2=20fixes=20?= =?UTF-8?q?=E2=80=94=20320px=20button=20stack=20+=20real=20click-through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round-2 review flagged three remaining items on the M-1A/M-1B overlay work: 1. At 320px (iPhone SE) the two nowrap scope buttons would overflow .search-actions. Add a @media (max-width: 400px) rule that switches .search-actions to flex-direction: column so they stack. 2. The z-index Playwright check was only computed-style math; it would miss an ancestor stacking-context trap or pointer interception. The dropdown spec now adds a real document.elementFromPoint() hit-test at the geometric intersection of the dropdown and overlay rects. 3. Added a dedicated 320px viewport test that asserts both (a) the overlay clears the toolbar and (b) no button overflows its parent row at that width. Also picked up the spec hygiene notes: - test.describe.configure({ timeout: 90000 }) — Cesium/OJS boot is slow on CI; default 30s was too tight. - Mirror-input spec now uses a waitForBootReady() helper that waits for window._ojs before calling value('zoomWatcher'), mirroring the pattern used elsewhere in the suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 7 ++ tests/playwright/explorer-map-overlay.spec.js | 86 ++++++++++++++++--- 2 files changed, 83 insertions(+), 10 deletions(-) diff --git a/explorer.qmd b/explorer.qmd index 9751d84..cf7c6ac 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -89,6 +89,13 @@ format: width: calc(100% - 58px); } } + @media (max-width: 400px) { + /* At iPhone-SE-ish widths the two scope buttons would otherwise + overflow because of their nowrap labels. Stack them vertically. */ + .map-search-overlay .search-actions { + flex-direction: column; + } + } .sidebar-search { display: flex; flex-direction: column; gap: 4px; } .sidebar-search input { width: 100%; diff --git a/tests/playwright/explorer-map-overlay.spec.js b/tests/playwright/explorer-map-overlay.spec.js index 5b67c84..d5d3f0a 100644 --- a/tests/playwright/explorer-map-overlay.spec.js +++ b/tests/playwright/explorer-map-overlay.spec.js @@ -3,6 +3,10 @@ const { test, expect } = require('@playwright/test'); const BASE_URL = process.env.TEST_URL || 'http://localhost:5860'; const EXPLORER_PATH = '/explorer.html'; +// Cesium + OJS boot can be slow on CI; the in-map-overlay specs all wait +// for #cesiumContainer + toolbar render before measuring. +test.describe.configure({ timeout: 90000 }); + async function getRect(page, selector) { return await page.locator(selector).first().evaluate((el) => { const r = el.getBoundingClientRect(); @@ -14,6 +18,16 @@ function rectsOverlap(a, b) { return !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top); } +async function waitForBootReady(page) { + // Wait for the OJS runtime to attach, then wait for zoomWatcher to resolve + // (it returns "active" once boot hydration + listener registration are + // complete — same pattern used in explorer-layout-stability.spec.js). + await page.waitForFunction(() => !!window._ojs && !!window._ojs.ojsConnector, null, { timeout: 60000 }); + await page.evaluate(async () => { + return await window._ojs.ojsConnector.mainModule.value('zoomWatcher'); + }); +} + test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', () => { test('desktop: overlay does not cover Cesium toolbar buttons', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); @@ -29,7 +43,6 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', const toolbar = await getRect(page, '.cesium-viewer-toolbar'); expect(rectsOverlap(overlay, toolbar), `overlay ${JSON.stringify(overlay)} overlaps toolbar ${JSON.stringify(toolbar)}`).toBeFalsy(); - // Each individual toolbar child button should also be unobstructed. const buttonCount = await page.locator('.cesium-viewer-toolbar > *').count(); expect(buttonCount).toBeGreaterThan(0); for (let i = 0; i < buttonCount; i++) { @@ -56,7 +69,38 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', expect(rectsOverlap(overlay, toolbar), `mobile overlay ${JSON.stringify(overlay)} overlaps toolbar ${JSON.stringify(toolbar)}`).toBeFalsy(); }); - test('base-layer picker dropdown opens above the search overlay', async ({ page }) => { + test('iPhone SE (320px): overlay clears toolbar and search buttons do not overflow', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.waitForSelector('#cesiumContainer', { timeout: 30000 }); + await page.waitForSelector('.cesium-viewer-toolbar', { timeout: 30000 }); + await page.waitForSelector('.map-search-overlay', { timeout: 10000 }); + + const overlay = await getRect(page, '.map-search-overlay'); + const toolbar = await getRect(page, '.cesium-viewer-toolbar'); + expect(rectsOverlap(overlay, toolbar)).toBeFalsy(); + + // The two scope buttons must not overflow the .search-actions row at + // narrow widths. The 400px media query stacks them vertically; assert + // that no button's right edge exceeds its parent's right edge. + const overflow = await page.evaluate(() => { + const actions = document.querySelector('.map-search-overlay .search-actions'); + if (!actions) return { error: 'no .search-actions' }; + const aRect = actions.getBoundingClientRect(); + return [...actions.querySelectorAll('button')].map((b) => { + const r = b.getBoundingClientRect(); + return { text: b.textContent, overflows: r.right > aRect.right + 0.5 || r.left < aRect.left - 0.5 }; + }); + }); + for (const row of overflow) { + expect(row.overflows, `button "${row.text}" overflows .search-actions at 320px`).toBeFalsy(); + } + }); + + test('base-layer picker dropdown is clickable (not occluded) above the overlay', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await page.goto(`${BASE_URL}${EXPLORER_PATH}#v=1&lat=20&lng=0&alt=10000000`, { waitUntil: 'domcontentloaded', @@ -69,16 +113,41 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', const dropdown = page.locator('.cesium-baseLayerPicker-dropDown').first(); await expect(dropdown).toBeVisible({ timeout: 5000 }); - // The dropdown geometrically overlaps the overlay area; what matters is - // that the dropdown wins the z-stack so its options are clickable. - // Resolve effective z-index of both via getComputedStyle. + // z-index sanity check. const zCompare = await page.evaluate(() => { const dd = document.querySelector('.cesium-baseLayerPicker-dropDown'); const ov = document.querySelector('.map-search-overlay'); const z = (el) => parseInt(getComputedStyle(el).zIndex, 10); return { dd: z(dd), ov: z(ov) }; }); - expect(zCompare.dd, `base-layer-picker dropdown z-index (${zCompare.dd}) must beat overlay (${zCompare.ov})`).toBeGreaterThan(zCompare.ov); + expect(zCompare.dd, `dropdown z-index (${zCompare.dd}) must beat overlay (${zCompare.ov})`).toBeGreaterThan(zCompare.ov); + + // Real hit-test: at a point inside the dropdown that overlaps the + // overlay's bounding box, elementFromPoint must return the dropdown + // (or one of its descendants) — not the overlay. + const hit = await page.evaluate(() => { + const dd = document.querySelector('.cesium-baseLayerPicker-dropDown'); + const ov = document.querySelector('.map-search-overlay'); + const ddR = dd.getBoundingClientRect(); + const ovR = ov.getBoundingClientRect(); + // Find an x,y inside the intersection of the two rects, if any. + const x = Math.max(ddR.left, ovR.left) + 4; + const y = Math.max(ddR.top, ovR.top) + 4; + const overlap = !(ddR.right <= ovR.left || ovR.right <= ddR.left || ddR.bottom <= ovR.top || ovR.bottom <= ddR.top); + if (!overlap) return { overlap: false }; + const hitEl = document.elementFromPoint(x, y); + return { + overlap: true, + x, y, + hitTag: hitEl && hitEl.tagName, + hitInDropdown: !!(hitEl && dd.contains(hitEl)), + hitInOverlay: !!(hitEl && ov.contains(hitEl)), + }; + }); + // Either there's no geometric overlap (no risk), or the dropdown wins. + if (hit.overlap) { + expect(hit.hitInDropdown, `elementFromPoint at (${hit.x},${hit.y}) hit ${hit.hitTag}, expected dropdown descendant. hitInOverlay=${hit.hitInOverlay}`).toBeTruthy(); + } }); test('sidebar search input mirrors in-map search input', async ({ page }) => { @@ -90,10 +159,7 @@ test.describe('Map search overlay — Cesium toolbar coexistence (#200 / M-1A)', await page.waitForSelector('#sampleSearch', { timeout: 30000 }); await page.waitForSelector('#sampleSearchSidebar', { timeout: 10000 }); - // The mirror is wired in the zoomWatcher OJS cell — wait for it to be ready. - await page.evaluate(async () => { - return await window._ojs.ojsConnector.mainModule.value('zoomWatcher'); - }); + await waitForBootReady(page); await page.locator('#sampleSearchSidebar').fill('pottery'); await expect(page.locator('#sampleSearch')).toHaveValue('pottery'); From c53ed19d777eebb88a9d94fa58160134a392b083 Mon Sep 17 00:00:00 2001 From: Raymond Yee Date: Wed, 13 May 2026 15:08:32 -0700 Subject: [PATCH 05/11] explorer: add display-only color legend at bottom of map (M-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a horizontal swatch row centered above Cesium's bottom credits row, showing the four source colors with their display names. Mockup requirement is "users can identify what each color means without hunting the sidebar"; functional toggle/show-hide stays in #sourceFilter as the single source of truth. The legend is marked aria-hidden="true" so screen readers don't read the palette twice (the sidebar source filter exposes the same information via labeled checkboxes). It also has pointer-events: none so it never intercepts globe interaction. Hardcoded colors mirror assets/js/source-palette.js. We could fetch SOURCE_COLORS dynamically at render-time, but that pulls a static overlay into the OJS reactive graph for no real benefit — the palette is stable and audited via issue #113. Wraps onto multiple rows below 600px viewport width. Co-Authored-By: Claude Opus 4.7 (1M context) --- explorer.qmd | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/explorer.qmd b/explorer.qmd index cf7c6ac..fa3103a 100644 --- a/explorer.qmd +++ b/explorer.qmd @@ -96,6 +96,51 @@ format: flex-direction: column; } } + /* Display-only color legend at bottom-center of the map. The functional + source filter (toggle/show-hide) lives in #sourceFilter in the side + panel. aria-hidden="true" on this element keeps screen readers from + reading the palette twice. Sits above Cesium's bottom-left credits. */ + .map-color-legend { + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + display: flex; + gap: 14px; + font-size: 12px; + color: #333; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(4px); + padding: 6px 12px; + border-radius: 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + pointer-events: none; /* never intercept globe interaction */ + white-space: nowrap; + } + .map-color-legend > span { + display: inline-flex; + align-items: center; + gap: 4px; + } + .map-color-legend .map-color-legend-dot { + width: 9px; + height: 9px; + border-radius: 50%; + display: inline-block; + } + @media (max-width: 600px) { + /* Below 600px the four-source row gets cramped — let it wrap and + shrink the padding so it doesn't crowd the map. */ + .map-color-legend { + flex-wrap: wrap; + gap: 6px 10px; + max-width: calc(100% - 24px); + justify-content: center; + font-size: 11px; + padding: 4px 8px; + } + } .sidebar-search { display: flex; flex-direction: column; gap: 4px; } .sidebar-search input { width: 100%; @@ -389,6 +434,12 @@ Searches labels, descriptions, and place names. First search can take 10
+