mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #394 from bilawalsidhu/refactor/map-source-controls
refactor: isolate Map Source controls
This commit is contained in:
14 files changed
+654
-79
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -315,5 +315,15 @@
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/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',
|
||||
);
|
||||
await page.evaluate(async () => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
await manager.setMapStack('photoreal');
|
||||
manager.setPanelCollapsed('control-panel', false, {
|
||||
persist: false,
|
||||
syncShare: false,
|
||||
});
|
||||
document.querySelector('.map-stack-chip.active')?.focus();
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
viewer.camera.cancelFlight?.();
|
||||
viewer.scene.tweens?.removeAll?.();
|
||||
viewer.camera.setView({
|
||||
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||
longitude: (-97.7431 * Math.PI) / 180,
|
||||
latitude: (30.2568 * Math.PI) / 180,
|
||||
height: 1200,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -0.85, roll: 0 },
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
),
|
||||
);
|
||||
const settled = await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const primitives = window.__godsEyeView.viewer.scene.primitives;
|
||||
for (let i = 0; i < primitives.length; i++) {
|
||||
const primitive = primitives.get(i);
|
||||
if (
|
||||
typeof primitive.tilesLoaded === 'boolean' &&
|
||||
!primitive.tilesLoaded
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
check('visible tile content settles before visual captures', settled);
|
||||
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.evaluate(() =>
|
||||
window.__godsEyeView.styleManager.setPanelCollapsed(
|
||||
'control-panel',
|
||||
false,
|
||||
{ persist: false, syncShare: false },
|
||||
),
|
||||
);
|
||||
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;
|
||||
+30
-12
@@ -1,8 +1,8 @@
|
||||
// MAP STACK source chips — the always-visible replacement for the `<select>`
|
||||
// that used to sit in the Map Stack panel. One button per stack, rendered from
|
||||
// `MapStackController.getStacks()`. The four owner-approved sources below are
|
||||
// `MapStackController.getStacks()`. The approved sources below are
|
||||
// the whole shipped set; keeping the allowlist explicit means a stack added to
|
||||
// `MAP_STACKS` for internal use cannot reach the tray until someone names it
|
||||
// `MAP_STACKS` for another purpose cannot reach the tray until someone names it
|
||||
// here.
|
||||
//
|
||||
// The chips are a control SURFACE only: selecting one calls back into the same
|
||||
@@ -40,7 +40,9 @@ export function mapStackChipModel(stack, activeId) {
|
||||
const fallbackReason = requiresIon
|
||||
? keySetupRequirement('cesium-ion')
|
||||
: `${label || 'This map stack'} is unavailable`;
|
||||
const unavailableHint = available ? '' : String(stack?.unavailableReason || fallbackReason);
|
||||
const unavailableHint = available
|
||||
? ''
|
||||
: String(stack?.unavailableReason || fallbackReason);
|
||||
return {
|
||||
id: String(stack?.id ?? ''),
|
||||
label,
|
||||
@@ -60,13 +62,13 @@ export function mapStackChipModel(stack, activeId) {
|
||||
* @param {Array<object>} stacks - `MapStackController.getStacks()` output.
|
||||
* @param {string|null} activeId - Currently active stack id.
|
||||
* @returns {Array<object>} One chip model per approved presentation id, in
|
||||
* `PRESENTED_MAP_STACK_IDS` order; internal and future stacks stay hidden.
|
||||
* `PRESENTED_MAP_STACK_IDS` order; unlisted stacks stay outside this presentation.
|
||||
*/
|
||||
export function mapStackChipModels(stacks, activeId) {
|
||||
const stacksById = new Map((Array.isArray(stacks) ? stacks : [])
|
||||
.map((stack) => [stack?.id, stack]));
|
||||
return PRESENTED_MAP_STACK_IDS
|
||||
.map((id) => stacksById.get(id))
|
||||
const stacksById = new Map(
|
||||
(Array.isArray(stacks) ? stacks : []).map((stack) => [stack?.id, stack]),
|
||||
);
|
||||
return PRESENTED_MAP_STACK_IDS.map((id) => stacksById.get(id))
|
||||
.filter(Boolean)
|
||||
.map((stack) => mapStackChipModel(stack, activeId));
|
||||
}
|
||||
@@ -79,9 +81,20 @@ export function mapStackChipModels(stacks, activeId) {
|
||||
* @param {string|null} [options.activeId] - Currently active stack id.
|
||||
* @param {(stackId: string) => void} [options.onSelect] - Selection callback.
|
||||
* @param {Document} [options.doc] - Document override (tests).
|
||||
* @param {(element: HTMLElement, type: string, listener: Function) => void} [options.bind] - Listener owner override.
|
||||
* @returns {Array<object>} The rendered chip models.
|
||||
*/
|
||||
export function renderMapStackChips(container, stacks, { activeId = null, onSelect = null, doc } = {}) {
|
||||
export function renderMapStackChips(
|
||||
container,
|
||||
stacks,
|
||||
{
|
||||
activeId = null,
|
||||
onSelect = null,
|
||||
doc,
|
||||
bind = (element, type, listener) =>
|
||||
element.addEventListener(type, listener),
|
||||
} = {},
|
||||
) {
|
||||
if (!container) return [];
|
||||
const ownerDoc = doc || container.ownerDocument || globalThis.document;
|
||||
if (!ownerDoc?.createElement) return [];
|
||||
@@ -96,13 +109,18 @@ export function renderMapStackChips(container, stacks, { activeId = null, onSele
|
||||
MAP_STACK_CHIP_CLASS,
|
||||
model.active ? 'active' : '',
|
||||
model.available ? '' : 'unavailable',
|
||||
].filter(Boolean).join(' ');
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
chip.dataset.stackId = model.id;
|
||||
chip.title = model.title;
|
||||
chip.setAttribute('aria-pressed', String(model.active));
|
||||
chip.setAttribute('aria-disabled', String(!model.available));
|
||||
if (!model.available) {
|
||||
chip.setAttribute('aria-label', `${model.label} unavailable: ${model.unavailableHint}`);
|
||||
chip.setAttribute(
|
||||
'aria-label',
|
||||
`${model.label} unavailable: ${model.unavailableHint}`,
|
||||
);
|
||||
}
|
||||
|
||||
const label = ownerDoc.createElement('span');
|
||||
@@ -117,7 +135,7 @@ export function renderMapStackChips(container, stacks, { activeId = null, onSele
|
||||
chip.appendChild(requirement);
|
||||
}
|
||||
|
||||
chip.addEventListener('click', () => {
|
||||
bind(chip, 'click', () => {
|
||||
if (!model.available) return;
|
||||
onSelect?.(model.id);
|
||||
});
|
||||
|
||||
+16
-20
@@ -347,26 +347,22 @@ test('the Visual Presets tray owns Map Source and the retired left panel is abse
|
||||
assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
|
||||
assert.match(ui, /querySelector\('\.map-stack-chip\.active'\)\s*\|\| panel\.querySelector\('\.map-stack-chip'\)/);
|
||||
|
||||
assert.match(
|
||||
ui,
|
||||
/renderMapStackChips\(this\._mapStackChips, this\.mapStackController\.getStacks\(\), \{[\s\S]*?onSelect: \(stackId\) => \{ this\._setMapStack\(stackId\); \}/,
|
||||
'chips must dispatch through the same _setMapStack path the dropdown used',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/_renderMapStackState\(state\) \{[\s\S]*?syncMapStackChips\(this\._mapStackChips, state\.activeId\)/,
|
||||
'the active chip must be re-synced from controller state',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/window\.addEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/,
|
||||
'provider-driven fallback must re-sync the UI without a user click',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/window\.removeEventListener\('gev:map-stack-changed', this\._mapStackChangeHandler\)/,
|
||||
'the provider-driven state listener must be released with StyleManager',
|
||||
);
|
||||
const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8');
|
||||
assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
|
||||
'the application action uses the component selection path');
|
||||
assert.match(controls, /onSelect: [\s\S]*?select\(id\)/,
|
||||
'chip clicks use the same component selection path');
|
||||
assert.match(controls, /await controller\.setStack\(stackId\)/,
|
||||
'selection still delegates source loading to the map controller');
|
||||
assert.match(controls, /render\(controller\.getState\(\)\)/,
|
||||
'the active chip is re-synced from actual controller state');
|
||||
assert.match(ui, /window\.addEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'provider-driven fallback reaches the component without a user click');
|
||||
assert.match(ui, /window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'the provider-driven subscription has an explicit remover');
|
||||
assert.match(controls, /unsubscribe\?\.\(\)/,
|
||||
'component destruction releases its subscription');
|
||||
|
||||
});
|
||||
|
||||
test('Esri fallbacks report and attribute the imagery source actually rendered', () => {
|
||||
|
||||
@@ -691,6 +691,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
|
||||
const synchronous = disposal.slice(0, firstAwait);
|
||||
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._visualEffects\.stop\(\)/);
|
||||
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createMapSourceControls } from './ui/mapSource.js';
|
||||
import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js';
|
||||
import { bindDisplayControls } from './ui/displayControls.js';
|
||||
import { bindApplicationShortcuts, createStyleParameters } from './ui/visualInput.js';
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
isExplicitLayerStateOrigin,
|
||||
LayerStateCoordinator,
|
||||
} from './data/layerState.js';
|
||||
import { renderMapStackChips, syncMapStackChips } from './mapStackChips.js';
|
||||
import { OrbitController } from './orbit.js';
|
||||
import {
|
||||
CelestialRing,
|
||||
@@ -3329,27 +3329,20 @@ export class StyleManager {
|
||||
* @returns {void}
|
||||
*/
|
||||
_initMapStackControl() {
|
||||
if (!this._mapStackChips || !this.mapStackController) return;
|
||||
|
||||
if (!this._mapStackChangeHandler) {
|
||||
// Provider-driven transitions (notably Esri tile-error fallback) do not
|
||||
// pass through `_setMapStack()`. Follow the controller's existing public
|
||||
// event so the lit tile, the status line, AND the durable share state all
|
||||
// describe the rendered source — without the share sync, a silent
|
||||
// fallback leaves copyLink() encoding a stack that is no longer shown.
|
||||
this._mapStackChangeHandler = (event) => {
|
||||
this._renderMapStackState(event.detail);
|
||||
this._syncShareState();
|
||||
};
|
||||
window.addEventListener('gev:map-stack-changed', this._mapStackChangeHandler);
|
||||
}
|
||||
|
||||
renderMapStackChips(this._mapStackChips, this.mapStackController.getStacks(), {
|
||||
activeId: this.mapStackController.getActiveId(),
|
||||
onSelect: (stackId) => { this._setMapStack(stackId); },
|
||||
if (!this.mapStackController) return;
|
||||
this._mapSourceControls?.destroy();
|
||||
this._mapSourceControls = createMapSourceControls({
|
||||
container: this._mapStackChips,
|
||||
statusElement: this._mapStackStatus,
|
||||
controller: this.mapStackController,
|
||||
subscribe: (onChange) => {
|
||||
window.addEventListener('gev:map-stack-changed', onChange);
|
||||
return () => window.removeEventListener('gev:map-stack-changed', onChange);
|
||||
},
|
||||
claimSelection: () => this.shareLinkManager?.claimRestoreLane?.('map'),
|
||||
onStateChanged: () => this._syncShareState(),
|
||||
onError: (message) => this._showToast(message),
|
||||
});
|
||||
|
||||
this._renderMapStackState(this.mapStackController.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3361,16 +3354,7 @@ export class StyleManager {
|
||||
*/
|
||||
async _setMapStack(stackId, { syncShare = true } = {}) {
|
||||
if (!this.mapStackController) return;
|
||||
if (syncShare) this.shareLinkManager?.claimRestoreLane?.('map');
|
||||
const before = this.mapStackController.getActiveId();
|
||||
this._renderMapStackState(this.mapStackController.getState('switching'));
|
||||
const state = await this.mapStackController.setStack(stackId);
|
||||
this._renderMapStackState(state);
|
||||
|
||||
if (state?.activeId === before && stackId !== before && state?.lastError) {
|
||||
this._showToast(state.lastError);
|
||||
}
|
||||
if (syncShare) this._syncShareState();
|
||||
return this._mapSourceControls.select(stackId, { syncShare });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3381,16 +3365,7 @@ export class StyleManager {
|
||||
* @returns {void}
|
||||
*/
|
||||
_renderMapStackState(state) {
|
||||
if (!state) return;
|
||||
syncMapStackChips(this._mapStackChips, state.activeId);
|
||||
if (this._mapStackStatus) {
|
||||
const stack = state.activeStack;
|
||||
const label = state.status === 'switching'
|
||||
? '...'
|
||||
: (stack?.shortLabel || stack?.label || 'MAP');
|
||||
this._mapStackStatus.textContent = label;
|
||||
this._mapStackStatus.classList.toggle('warn', !!state.lastError);
|
||||
}
|
||||
this._mapSourceControls?.render(state);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9401,6 +9376,7 @@ export class StyleManager {
|
||||
this._disposed = true;
|
||||
this._applicationShortcuts?.destroy();
|
||||
this._displayControls?.destroy();
|
||||
this._mapSourceControls?.destroy();
|
||||
this._visualEffects.stop();
|
||||
this._styleParameters?.destroy();
|
||||
for (const control of this._panelDisclosureControls || []) control.destroy();
|
||||
@@ -9430,10 +9406,7 @@ export class StyleManager {
|
||||
window.removeEventListener('gev:awareness-subject-cleared', this._awarenessClearedHandler);
|
||||
this._awarenessClearedHandler = null;
|
||||
}
|
||||
if (this._mapStackChangeHandler) {
|
||||
window.removeEventListener('gev:map-stack-changed', this._mapStackChangeHandler);
|
||||
this._mapStackChangeHandler = null;
|
||||
}
|
||||
|
||||
// Invalidate any in-flight Context transaction the same way a newer request
|
||||
// would. Without this, a reinstatement already past its awaits could
|
||||
// re-enable a mode's entry layer and republish `_contextMode` while the
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export { createMapSourceControls } from './mapSourceControls.js';
|
||||
export {
|
||||
MAP_STACK_CHIP_CLASS,
|
||||
PRESENTED_MAP_STACK_IDS,
|
||||
mapStackChipModel,
|
||||
mapStackChipModels,
|
||||
renderMapStackChips,
|
||||
syncMapStackChips,
|
||||
} from '../mapStackChips.js';
|
||||
@@ -0,0 +1,88 @@
|
||||
import { renderMapStackChips, syncMapStackChips } from '../mapStackChips.js';
|
||||
|
||||
/**
|
||||
* Own Map Source presentation and selection without constructing map providers.
|
||||
* The supplied controller remains authoritative for availability and active state.
|
||||
*/
|
||||
export function createMapSourceControls({
|
||||
container,
|
||||
statusElement,
|
||||
controller,
|
||||
subscribe,
|
||||
claimSelection = () => {},
|
||||
onStateChanged = () => {},
|
||||
onError = () => {},
|
||||
}) {
|
||||
let destroyed = false;
|
||||
let generation = 0;
|
||||
const removers = [];
|
||||
const bind = (element, type, listener) => {
|
||||
element.addEventListener(type, listener);
|
||||
removers.push(() => element.removeEventListener(type, listener));
|
||||
};
|
||||
function render(state) {
|
||||
if (destroyed || !state) return;
|
||||
syncMapStackChips(container, state.activeId);
|
||||
if (statusElement) {
|
||||
const stack = state.activeStack;
|
||||
statusElement.textContent =
|
||||
state.status === 'switching'
|
||||
? '...'
|
||||
: stack?.shortLabel || stack?.label || 'MAP';
|
||||
statusElement.classList.toggle('warn', !!state.lastError);
|
||||
}
|
||||
}
|
||||
async function select(stackId, { syncShare = true } = {}) {
|
||||
if (destroyed) return null;
|
||||
const current = ++generation;
|
||||
if (syncShare) claimSelection();
|
||||
const before = controller.getActiveId();
|
||||
render(controller.getState('switching'));
|
||||
let state;
|
||||
try {
|
||||
state = await controller.setStack(stackId);
|
||||
} catch (error) {
|
||||
if (!destroyed && current === generation) {
|
||||
render(controller.getState());
|
||||
onError(error?.message || String(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (destroyed || current !== generation) return state;
|
||||
render(controller.getState());
|
||||
if (state?.activeId === before && stackId !== before && state?.lastError)
|
||||
onError(state.lastError);
|
||||
if (syncShare) onStateChanged();
|
||||
return state;
|
||||
}
|
||||
function refresh() {
|
||||
if (destroyed) return;
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
renderMapStackChips(container, controller.getStacks(), {
|
||||
activeId: controller.getActiveId(),
|
||||
onSelect: (id) => {
|
||||
void select(id).catch(() => {});
|
||||
},
|
||||
bind,
|
||||
});
|
||||
render(controller.getState());
|
||||
}
|
||||
const unsubscribe = subscribe(() => {
|
||||
if (destroyed) return;
|
||||
render(controller.getState());
|
||||
onStateChanged();
|
||||
});
|
||||
refresh();
|
||||
return {
|
||||
render,
|
||||
select,
|
||||
refresh,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
generation++;
|
||||
unsubscribe?.();
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMapSourceControls } from './mapSourceControls.js';
|
||||
|
||||
function makeElement(tagName = 'div') {
|
||||
const element = {
|
||||
tagName,
|
||||
type: '',
|
||||
className: '',
|
||||
title: '',
|
||||
disabled: false,
|
||||
textContent: '',
|
||||
dataset: {},
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
children: [],
|
||||
classList: {
|
||||
toggle(name, force) {
|
||||
const classes = new Set(
|
||||
String(element.className).split(/\s+/).filter(Boolean),
|
||||
);
|
||||
const next = force === undefined ? !classes.has(name) : !!force;
|
||||
if (next) classes.add(name);
|
||||
else classes.delete(name);
|
||||
element.className = [...classes].join(' ');
|
||||
},
|
||||
contains(name) {
|
||||
return String(element.className).split(/\s+/).includes(name);
|
||||
},
|
||||
},
|
||||
appendChild(child) {
|
||||
element.children.push(child);
|
||||
return child;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
element.attributes[name] = String(value);
|
||||
},
|
||||
getAttribute(name) {
|
||||
return element.attributes[name] ?? null;
|
||||
},
|
||||
addEventListener(type, handler) {
|
||||
(element.listeners[type] ||= []).push(handler);
|
||||
},
|
||||
removeEventListener(type, handler) {
|
||||
element.listeners[type] = (element.listeners[type] || []).filter(
|
||||
(current) => current !== handler,
|
||||
);
|
||||
},
|
||||
click() {
|
||||
for (const handler of element.listeners.click || []) handler();
|
||||
},
|
||||
};
|
||||
Object.defineProperty(element, 'innerHTML', {
|
||||
get() {
|
||||
return '';
|
||||
},
|
||||
set() {
|
||||
element.children.length = 0;
|
||||
},
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const container = makeElement();
|
||||
container.ownerDocument = { createElement: (tag) => makeElement(tag) };
|
||||
const statusElement = makeElement();
|
||||
const sources = [
|
||||
{ id: 'osm', label: 'OSM' },
|
||||
{ id: 'esri-imagery', label: 'Esri' },
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing',
|
||||
available: false,
|
||||
unavailableReason: 'Unavailable for this test',
|
||||
},
|
||||
];
|
||||
let state = { activeId: 'osm', activeStack: sources[0], status: 'ready' };
|
||||
const listeners = new Set();
|
||||
const requests = [];
|
||||
const calls = [];
|
||||
const controller = {
|
||||
getStacks: () => sources,
|
||||
getActiveId: () => state.activeId,
|
||||
getState: (status) => ({ ...state, status: status || state.status }),
|
||||
setStack: (id) => {
|
||||
calls.push(['select', id]);
|
||||
return new Promise((resolve, reject) =>
|
||||
requests.push({ id, resolve, reject }),
|
||||
);
|
||||
},
|
||||
};
|
||||
const controls = createMapSourceControls({
|
||||
container,
|
||||
statusElement,
|
||||
controller,
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
claimSelection: () => calls.push(['claim']),
|
||||
onStateChanged: () => calls.push(['state']),
|
||||
onError: (message) => calls.push(['error', message]),
|
||||
});
|
||||
const chip = (id) =>
|
||||
container.children.find((el) => el.dataset.stackId === id);
|
||||
function change(id, lastError = null) {
|
||||
state = {
|
||||
activeId: id,
|
||||
activeStack: sources.find((source) => source.id === id),
|
||||
status: 'ready',
|
||||
lastError,
|
||||
};
|
||||
return controller.getState();
|
||||
}
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
return {
|
||||
container,
|
||||
statusElement,
|
||||
controller,
|
||||
controls,
|
||||
requests,
|
||||
calls,
|
||||
listeners,
|
||||
chip,
|
||||
change,
|
||||
emit,
|
||||
};
|
||||
}
|
||||
|
||||
test('initial presentation uses actual state and retains unavailable chip semantics', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(f.statusElement.textContent, 'OSM');
|
||||
const unavailable = f.chip('bing-aerial');
|
||||
assert.equal(
|
||||
unavailable.disabled,
|
||||
false,
|
||||
'unavailable chips remain focusable',
|
||||
);
|
||||
assert.equal(unavailable.getAttribute('aria-disabled'), 'true');
|
||||
unavailable.click();
|
||||
assert.equal(f.requests.length, 0);
|
||||
f.controls.destroy();
|
||||
});
|
||||
|
||||
test('selection claims authority before requesting and never lights a pending source optimistically', async () => {
|
||||
const f = fixture();
|
||||
const pending = f.controls.select('esri-imagery');
|
||||
assert.deepEqual(f.calls, [['claim'], ['select', 'esri-imagery']]);
|
||||
assert.equal(f.statusElement.textContent, '...');
|
||||
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
|
||||
f.requests[0].resolve(f.change('esri-imagery'));
|
||||
await pending;
|
||||
assert.equal(f.chip('esri-imagery').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(f.statusElement.textContent, 'Esri');
|
||||
f.controls.destroy();
|
||||
});
|
||||
|
||||
test('provider-driven fallback updates the active chip and durable state notification', () => {
|
||||
const f = fixture();
|
||||
f.change('esri-imagery');
|
||||
f.emit();
|
||||
f.change('osm', 'Source unavailable; using OSM');
|
||||
f.emit();
|
||||
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(f.statusElement.textContent, 'OSM');
|
||||
assert.equal(f.statusElement.classList.contains('warn'), true);
|
||||
assert.deepEqual(f.calls, [['state'], ['state']]);
|
||||
f.controls.destroy();
|
||||
});
|
||||
|
||||
test('a late superseded response cannot overwrite the latest displayed selection', async () => {
|
||||
const f = fixture();
|
||||
const first = f.controls.select('esri-imagery');
|
||||
const second = f.controls.select('osm');
|
||||
f.requests[1].resolve(f.change('osm'));
|
||||
await second;
|
||||
const count = f.calls.length;
|
||||
f.requests[0].resolve({
|
||||
activeId: 'esri-imagery',
|
||||
activeStack: { label: 'old' },
|
||||
lastError: 'obsolete failure',
|
||||
});
|
||||
await first;
|
||||
assert.equal(f.statusElement.textContent, 'OSM');
|
||||
assert.equal(f.calls.length, count);
|
||||
f.controls.destroy();
|
||||
});
|
||||
|
||||
test('failed selection keeps the real source lit and reports its error', async () => {
|
||||
const f = fixture();
|
||||
const pending = f.controls.select('esri-imagery');
|
||||
f.requests[0].resolve(f.change('osm', 'Unavailable'));
|
||||
await pending;
|
||||
assert.equal(f.chip('osm').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(f.statusElement.classList.contains('warn'), true);
|
||||
assert.ok(
|
||||
f.calls.some((call) => call[0] === 'error' && call[1] === 'Unavailable'),
|
||||
);
|
||||
f.controls.destroy();
|
||||
});
|
||||
|
||||
test('rejected selection leaves switching presentation and propagates a useful error', async () => {
|
||||
const f = fixture();
|
||||
const pending = f.controls.select('esri-imagery');
|
||||
f.requests[0].reject(new Error('Request failed'));
|
||||
await assert.rejects(pending, /Request failed/);
|
||||
assert.equal(f.statusElement.textContent, 'OSM');
|
||||
assert.ok(
|
||||
f.calls.some((call) => call[0] === 'error' && call[1] === 'Request failed'),
|
||||
);
|
||||
f.controls.destroy();
|
||||
});
|
||||
|
||||
test('restoration may select without claiming or publishing a user gesture', async () => {
|
||||
const f = fixture();
|
||||
const pending = f.controls.select('esri-imagery', { syncShare: false });
|
||||
f.requests[0].resolve(f.change('esri-imagery'));
|
||||
await pending;
|
||||
assert.deepEqual(f.calls, [['select', 'esri-imagery']]);
|
||||
f.controls.destroy();
|
||||
});
|
||||
|
||||
test('refresh removes detached chip listeners and destruction removes all subscriptions', () => {
|
||||
const f = fixture();
|
||||
const old = f.chip('esri-imagery');
|
||||
f.controls.refresh();
|
||||
old.click();
|
||||
assert.equal(f.requests.length, 0);
|
||||
const current = f.chip('esri-imagery');
|
||||
f.controls.destroy();
|
||||
f.controls.destroy();
|
||||
current.click();
|
||||
assert.equal(f.requests.length, 0);
|
||||
assert.equal(f.listeners.size, 0);
|
||||
});
|
||||
|
||||
test('completion after destruction cannot paint or notify', async () => {
|
||||
const f = fixture();
|
||||
const pending = f.controls.select('esri-imagery');
|
||||
f.controls.destroy();
|
||||
const count = f.calls.length;
|
||||
const label = f.statusElement.textContent;
|
||||
f.requests[0].resolve(f.change('esri-imagery'));
|
||||
await pending;
|
||||
assert.equal(f.calls.length, count);
|
||||
assert.equal(f.statusElement.textContent, label);
|
||||
assert.equal(await f.controls.select('osm'), null);
|
||||
});
|
||||
Reference in new issue
Block a user