refactor: isolate Map Source controls and selection lifecycle

This commit is contained in:
Sameh Khamis committed 2026-09-12 22:25:04 -07:00
1 parent 78199c2ab3
commit 72d531b18f
14 files changed
+405 -72

No files matched your search

+6 -5
View File
@@ -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
@@ -60,7 +60,7 @@ 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 : [])
@@ -79,9 +79,10 @@ 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 [];
@@ -117,7 +118,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
View File
@@ -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', () => {
+1
View File
@@ -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\(\)/);
});
+18 -45
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
export { createMapSourceControls } from './mapSourceControls.js';
export { MAP_STACK_CHIP_CLASS, PRESENTED_MAP_STACK_IDS, mapStackChipModel, mapStackChipModels, renderMapStackChips, syncMapStackChips } from '../mapStackChips.js';
+75
View File
@@ -0,0 +1,75 @@
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();
},
};
}
+179
View File
@@ -0,0 +1,179 @@
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);
});