From 72d531b18f2d5dbff71e99e870b57a000187afc3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:25:04 -0700 Subject: [PATCH] refactor: isolate Map Source controls and selection lifecycle --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 9 ++ docs/CURRENT-STATE.md | 9 ++ package.json | 3 +- scripts/format-scope.json | 7 +- scripts/package-boundaries.json | 5 + scripts/qa-map-source-controls.mjs | 75 ++++++++++++ src/mapStackChips.js | 11 +- src/mapStackChips.test.mjs | 36 +++--- src/sharelink.celestial.test.mjs | 1 + src/ui.js | 63 +++------- src/ui/mapSource.js | 2 + src/ui/mapSourceControls.js | 75 ++++++++++++ src/ui/mapSourceControls.test.mjs | 179 +++++++++++++++++++++++++++++ 14 files changed, 405 insertions(+), 72 deletions(-) create mode 100644 scripts/qa-map-source-controls.mjs create mode 100644 src/ui/mapSource.js create mode 100644 src/ui/mapSourceControls.js create mode 100644 src/ui/mapSourceControls.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5855ec39c..14f7fd9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback. + - Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown. - Extract Display control bindings with synchronous listener cleanup; preserve existing visual actions and native input behavior. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index db1e450bc..5ade016ed 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -260,3 +260,12 @@ releasing UI consumers, then destroy to remove owned stages and restore the borrowed bloom state. `ui/effects/bloom` exposes the pure intensity/version helpers without loading the renderer. UI presentation and product-action coordination remain in their callers. + +## Map Source controls + +`ui/maps` owns source-chip presentation, selection feedback and its subscription +lifetime. It receives the existing controller and explicit state/action callbacks; +it imports no renderer or application. Source construction and availability policy +remain with the map controller. Rebuilding controls removes their previous chip +listeners, and destruction suppresses late completions without owning or destroying +the supplied controller. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2461def9e..f01f25a4f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,14 @@ # God's Eye View Current State +## Map Source control ownership + +Map Source controls own chip listeners, source-state subscriptions and selection +feedback. Loading remains with the supplied map controller. The active chip +follows the source actually displayed, including fallback; obsolete completions +cannot overwrite a newer selection. Refresh and destruction revoke old listeners +and destruction suppresses late UI updates. Available choices and setup behavior +remain unchanged. + ## Visual effects ownership VisualEffects owns style-stage creation, crossfades, animation scheduling, bloom diff --git a/package.json b/package.json index d6ecae4fb..3d2a7bbad 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "./ui/input": "./src/ui/visualInput.js", "./ui/display": "./src/ui/displayControls.js", "./ui/effects": "./src/ui/effects.js", - "./ui/effects/bloom": "./src/bloom.js" + "./ui/effects/bloom": "./src/bloom.js", + "./ui/maps": "./src/ui/mapSource.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 3409bdecf..44f6e3ab0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -148,5 +148,10 @@ "src/styles/anime.js", "src/styles/noir.js", "src/styles/snow.js", - "scripts/qa-visual-effects.mjs" + "scripts/qa-visual-effects.mjs", + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js", + "src/ui/mapSourceControls.test.mjs", + "src/mapStackChips.js", + "scripts/qa-map-source-controls.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a9d0ac286..30366f8b9 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -315,5 +315,10 @@ "src/styles/snow.js" ], "external": ["cesium"] + }, + "map-source-controls": { + "exports": ["./ui/maps"], + "modules": ["src/ui/mapSource.js", "src/ui/mapSourceControls.js", "src/mapStackChips.js", "src/keySetupCore.mjs"], + "external": [] } } diff --git a/scripts/qa-map-source-controls.mjs b/scripts/qa-map-source-controls.mjs new file mode 100644 index 000000000..501475566 --- /dev/null +++ b/scripts/qa-map-source-controls.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** Browser acceptance for Map Source control lifecycle and truthful selection. */ +import fs from 'node:fs'; +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', ...(process.platform === 'darwin' ? ['--use-angle=metal', '--enable-gpu'] : ['--use-gl=angle', '--use-angle=swiftshader'])] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +let failures = 0; +const check = (name, ok) => { console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`); if (!ok) failures++; }; +try { + await page.setViewport({ width: 1440, height: 900 }); + const url = new URL(process.env.QA_BASE_URL || 'http://127.0.0.1:4173'); + url.searchParams.set('welcome', '0'); + await page.goto(url.href, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => { + const cover = document.getElementById('loading-screen'); + return window.__godsEyeView?.styleManager?._mapSourceControls && cover?.classList.contains('hidden') && Number(getComputedStyle(cover).opacity) === 0; + }, { timeout: 60_000 }); + await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const controller = manager.mapStackController; + const original = controller.setStack; + window.__qaMapSelections = []; + controller.setStack = function(id) { window.__qaMapSelections.push(id); return original.call(this, id); }; + manager.setPanelCollapsed('control-panel', false, { persist: false, syncShare: false }); + }); + await page.click('[data-stack-id="osm"]'); + await page.waitForFunction(() => window.__godsEyeView.styleManager.mapStackController.getActiveId() === 'osm'); + check('a native chip click requests OSM exactly once', await page.evaluate(() => window.__qaMapSelections.length === 1 && window.__qaMapSelections[0] === 'osm')); + check('active chip and status follow the displayed OSM source', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + return manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset.stackId === 'osm' && manager._mapStackStatus.textContent === manager.mapStackController.getState().activeStack.shortLabel; + })); + check('the public map action uses the same component and truthful result', await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const result = await manager.setMapStack('esri-imagery'); + const active = manager.mapStackController.getActiveId(); + return window.__qaMapSelections.at(-1) === 'esri-imagery' && manager._mapStackChips.querySelector('[aria-pressed="true"]')?.dataset.stackId === active && (result.ok || !!manager.mapStackController.getState().lastError); + })); + check('refresh revokes retained old chips', await page.evaluate(() => { + const manager = window.__godsEyeView.styleManager; + const old = manager._mapStackChips.querySelector('[data-stack-id="osm"]'); + manager._mapSourceControls.refresh(); + const before = window.__qaMapSelections.length; + old.click(); + return window.__qaMapSelections.length === before; + })); + check('refreshed chips still select once', await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const before = window.__qaMapSelections.length; + manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); + await Promise.resolve(); + return window.__qaMapSelections.length === before + 1; + })); + await page.waitForFunction(() => window.__godsEyeView.styleManager.mapStackController.getActiveId() === 'osm'); + fs.mkdirSync('qa-shots/map-source-controls', { recursive: true }); + await page.screenshot({ path: 'qa-shots/map-source-controls/desktop.png' }); + await page.setViewport({ width: 620, height: 900 }); + await page.waitForFunction(() => document.getElementById('left-panel-stack')?.dataset.layoutMode === 'mobile'); + await page.screenshot({ path: 'qa-shots/map-source-controls/narrow.png' }); + check('destroyed controls cannot issue requests from retained chips', await page.evaluate(async () => { + const manager = window.__godsEyeView.styleManager; + const before = window.__qaMapSelections.length; + manager._mapSourceControls.destroy(); + manager._mapStackChips.querySelector('[data-stack-id="osm"]').click(); + const result = await manager._mapSourceControls.select('esri-imagery'); + return result === null && window.__qaMapSelections.length === before; + })); + check('no uncaught browser errors', errors.length === 0); + if (errors.length) console.log(JSON.stringify(errors)); +} finally { await browser.close(); } +console.log(`RESULT: ${failures} failures`); +process.exitCode = failures ? 1 : 0; diff --git a/src/mapStackChips.js b/src/mapStackChips.js index c87e319b3..0717904e0 100644 --- a/src/mapStackChips.js +++ b/src/mapStackChips.js @@ -1,8 +1,8 @@ // MAP STACK source chips — the always-visible replacement for the `