Merge pull request #395 from bilawalsidhu/refactor/layer-panel

refactor: separate Layers panel presentation and feedback
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-12 22:49:55 -07:00
commit 20c1611354
18 files changed
+885 -424

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased] ## [Unreleased]
- Separate Layers panel presentation and clear-control bindings from layer lifecycle operations; revoke listeners and subscriptions on replacement or teardown.
- Extract Map Source controls with listener cleanup and protection against obsolete selection feedback. - 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. - Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown.
+10
View File
@@ -269,3 +269,13 @@ it imports no renderer or application. Source construction and availability poli
remain with the map controller. Rebuilding controls removes their previous chip remain with the map controller. Rebuilding controls removes their previous chip
listeners, and destruction suppresses late completions without owning or destroying listeners, and destruction suppresses late completions without owning or destroying
the supplied controller. the supplied controller.
## Layer panel
`ui/layers` exports the Layers panel and clear-control binding. Callers supply
snapshots, row descriptors, subscriptions and actions; the component imports no
layer implementation or application bootstrap. Layer transactions remain with
the caller. Hidden-page refresh scheduling remains an explicit callback.
`ui/layers/feedback` exposes the existing pure loading/notice reducers separately
from DOM controls. Scheduling and presentation stay with their callers.
+9
View File
@@ -1,5 +1,14 @@
# God's Eye View Current State # God's Eye View Current State
## Layer panel ownership
Layer rows, feed feedback, counts, focus-preserving chips and toggle listeners
are owned by a renderer-free panel component. The layer manager supplies current
snapshots, lifecycle actions and row descriptors. Remount and teardown remove
listeners and row subscriptions; obsolete completions do not repaint old rows.
The clear control presents busy state while its existing action owns the transaction.
## Map Source control ownership ## Map Source control ownership
Map Source controls own chip listeners, source-state subscriptions and selection Map Source controls own chip listeners, source-state subscriptions and selection
+3 -1
View File
@@ -126,6 +126,8 @@
"./ui/display": "./src/ui/displayControls.js", "./ui/display": "./src/ui/displayControls.js",
"./ui/effects": "./src/ui/effects.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" "./ui/maps": "./src/ui/mapSource.js",
"./ui/layers": "./src/ui/layers.js",
"./ui/layers/feedback": "./src/loadingFeedback.js"
} }
} }
+7 -1
View File
@@ -153,5 +153,11 @@
"src/ui/mapSourceControls.js", "src/ui/mapSourceControls.js",
"src/ui/mapSourceControls.test.mjs", "src/ui/mapSourceControls.test.mjs",
"src/mapStackChips.js", "src/mapStackChips.js",
"scripts/qa-map-source-controls.mjs" "scripts/qa-map-source-controls.mjs",
"src/ui/layers.js",
"src/ui/layerPanel.js",
"src/ui/clearLayersControl.js",
"scripts/qa-layer-panel.mjs",
"src/loadingFeedback.js",
"src/data/installationFeedback.js"
] ]
+11
View File
@@ -325,5 +325,16 @@
"src/keySetupCore.mjs" "src/keySetupCore.mjs"
], ],
"external": [] "external": []
},
"layer-panel": {
"exports": ["./ui/layers", "./ui/layers/feedback"],
"modules": [
"src/ui/layers.js",
"src/ui/layerPanel.js",
"src/ui/clearLayersControl.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js"
],
"external": []
} }
} }
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
/** Browser proof of Layers panel replacement and subscription ownership. */
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();
let failures = 0;
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const check = (name, passed) => {
console.log(`[${passed ? 'PASS' : 'FAIL'}] ${name}`);
if (!passed) failures++;
};
try {
await page.goto(
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
{ waitUntil: 'domcontentloaded' },
);
await page.waitForFunction(
() => window.__godsEyeView?.dataManager?._layerPanel,
{ timeout: 60000 },
);
const results = await page.evaluate(async () => {
const manager = window.__godsEyeView.dataManager;
const container = manager._toggleContainer;
const id = 'qa-panel-lifecycle';
let listener = null;
let enabled = 0;
window.__gevQaRegisterLayer(manager, {
id,
name: '<b>Literal layer</b>',
icon: '◌',
source: 'Local fixture',
updateInterval: -1,
init() {},
enable() {
enabled++;
},
disable() {},
update() {},
destroy() {},
getStats: () => ({ count: 1250, lastUpdate: Date.now(), stale: true }),
getRowControls: () => ({ chips: [] }),
setRowControlsListener: (callback) => {
listener = callback;
},
});
const result = [];
try {
manager.buildTogglePanel(container);
const row = () => container.querySelector(`[data-layer-id="${id}"]`);
const first = row().querySelector('.data-toggle-btn');
result.push([
'descriptor text is literal content',
row().querySelector('.data-name').textContent ===
'<b>Literal layer</b>' && !row().querySelector('b'),
]);
result.push([
'row subscription is installed',
typeof listener === 'function',
]);
manager.buildTogglePanel(container);
first.click();
await Promise.resolve();
result.push([
'a detached row cannot issue an enable action',
enabled === 0 && !manager.isEnabled(id),
]);
await manager.setEnabled(id, true);
result.push([
'count is presented from the supplied snapshot',
row().querySelector('.data-count').textContent === '1.3K',
]);
result.push([
'feed state reflects the settled layer snapshot',
row().querySelector('.data-toggle-btn').dataset.feedState === 'stale',
]);
const ui = window.__godsEyeView.styleManager;
ui._clearSelectedLayersBtn.click();
result.push([
'native clear activation presents busy state',
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'true' &&
!!ui._clearSelectedLayersPromise,
]);
await ui._clearSelectedLayersPromise;
result.push([
'clear settles through the existing layer transaction',
!manager.isEnabled(id) &&
ui._clearSelectedLayersBtn.getAttribute('aria-busy') === 'false',
]);
await manager.setEnabled(id, true);
const final = row().querySelector('.data-toggle-btn');
manager._layerPanel.destroy();
final.click();
await Promise.resolve();
result.push([
'destroyed panel releases row subscriptions',
listener === null,
]);
result.push([
'destroyed controls cannot change the layer',
manager.isEnabled(id),
]);
} finally {
manager._layerPanel?.destroy();
manager._layerPanel = null;
await window.__gevQaUnregisterLayer(manager, id);
manager.buildTogglePanel(container);
}
result.push([
'ordinary layer rows return after reassembly',
container.querySelectorAll('.data-toggle-row').length > 10,
]);
return result;
});
for (const [name, passed] of results) check(name, passed);
check('no uncaught browser errors', errors.length === 0);
} finally {
await browser.close();
}
console.log(`RESULT: ${failures} failures`);
process.exitCode = failures ? 1 : 0;
+3 -1
View File
@@ -746,7 +746,9 @@ test('Global Context names its mixed contact cycle without changing the stable m
test('Global Context uses its dedicated right rail without a duplicate Data Layers row', () => { test('Global Context uses its dedicated right rail without a duplicate Data Layers row', () => {
assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/); assert.match(contextLayer, /id:\s*'military-awareness'[\s\S]*?showInTogglePanel:\s*false/);
assert.match(manager, /if \(!layer\.showInTogglePanel\) continue;/); const panel = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'layerPanel.js'), 'utf8');
assert.match(panel, /if \(!layer\.showInTogglePanel\) continue;/);
assert.match(manager, /getLayers: \(\) => this\.getAll\(\)/);
assert.match(html, /id="global-context-panel"/); assert.match(html, /id="global-context-panel"/);
assert.match(html, /id="global-context-flights-btn"/); assert.match(html, /id="global-context-flights-btn"/);
assert.match(html, /id="global-context-missions-btn"/); assert.match(html, /id="global-context-missions-btn"/);
+4 -1
View File
@@ -157,5 +157,8 @@ test('Context async action buttons remain focused while busy', () => {
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`)); assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
} }
assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/); assert.doesNotMatch(clear, /_clearSelectedLayersBtn\.disabled\s*=\s*true/);
assert.match(clear, /_clearSelectedLayersBtn\.setAttribute\('aria-busy', 'true'\)/); assert.match(clear, /_clearLayersControl\?\.setBusy\(true\)/);
const control = readFileSync(new URL('./ui/clearLayersControl.js', import.meta.url), 'utf8');
assert.match(control, /button\.setAttribute\('aria-busy', String\(busy\)\)/);
assert.doesNotMatch(control, /button\.disabled\s*=/);
}); });
+6 -3
View File
@@ -5,14 +5,17 @@ export function installationFeedback(stats = {}, now = Date.now()) {
timeout: 'Overpass timed out', timeout: 'Overpass timed out',
query_failed: 'Overpass could not complete the query', query_failed: 'Overpass could not complete the query',
}; };
const reason = reasons[stats.failureReason] || 'Overpass temporarily unavailable'; const reason =
if (stats.loading) return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…'; reasons[stats.failureReason] || 'Overpass temporarily unavailable';
if (stats.loading)
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
if (stats.retryAt > 0) { if (stats.retryAt > 0) {
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000)); const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`; return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
} }
if (stats.status === 'unavailable') return reason; if (stats.status === 'unavailable') return reason;
if (stats.status === 'zoom-in') return 'Zoom in to search mapped installations'; if (stats.status === 'zoom-in')
return 'Zoom in to search mapped installations';
if (stats.stale) return 'Showing cached mapped sites'; if (stats.stale) return 'Showing cached mapped sites';
if (stats.status === 'idle') return 'Mapped sites not loaded'; if (stats.status === 'idle') return 'Mapped sites not loaded';
return 'Mapped sites loaded'; return 'Mapped sites loaded';
+25 -330
View File
@@ -1,5 +1,6 @@
import { governorRequestRender } from '../renderGovernor.js'; import { governorRequestRender } from '../renderGovernor.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; import { LayerPanel } from '../ui/layers.js';
export { layerFeedState } from '../ui/layers.js';
import { markDetectionSourcesChanged } from './detection.js'; import { markDetectionSourcesChanged } from './detection.js';
function cloneLayerParams(value) { function cloneLayerParams(value) {
if (Array.isArray(value)) return value.map(cloneLayerParams); if (Array.isArray(value)) return value.map(cloneLayerParams);
@@ -11,15 +12,6 @@ function cloneLayerParams(value) {
return value; return value;
} }
const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent'); const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent');
const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([ const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
'enabled-only', 'enabled-only',
@@ -65,53 +57,6 @@ function refreshFailureFromStats(stats, label) {
return null; return null;
} }
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
*/
export function layerFeedState(stats = {}) {
const state = stats || {};
const status = typeof state.status === 'string' ? state.status.toLowerCase() : '';
const source = `${state.source || ''} ${state.coverage || ''}`;
const hasExplicitFallback = typeof state.fallback === 'boolean';
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
const presentedError = state.error || state.lastError || state.managerRefreshError;
if (['unavailable', 'offline', 'down', 'error'].includes(status)) return 'unavailable';
if (
(presentedError || state.unavailable === true || state.available === false)
&& !hasPriorData
&& !GUIDANCE_STATUSES.includes(status)
) {
return 'unavailable';
}
if (state.loading) return 'loading';
// Guidance states ask the user to act (zoom in, run a search) — normal
// operation, not feed faults. One honesty carve-out: layers keep their
// rendered records through the guidance state, so a genuinely stale cache
// still reads STALE; a guidance prompt alone never reads DEGRADED.
if (GUIDANCE_STATUSES.includes(status)) {
return state.stale ? 'stale' : 'nominal';
}
if (
state.fallback === true
|| status === 'fallback'
|| state.mode === 'sim'
|| /\bfallback\b/i.test(source)
|| (!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
) {
return 'fallback';
}
if (state.stale || status === 'stale') return 'stale';
if (
state.degraded
|| presentedError
|| state.unavailable === true
|| state.available === false
) return 'degraded';
return 'nominal';
}
/** /**
* DataLayerManager — Manages registration, toggling, and update loops * DataLayerManager — Manages registration, toggling, and update loops
* for real-time data overlays on the CesiumJS globe. * for real-time data overlays on the CesiumJS globe.
@@ -1894,6 +1839,9 @@ export class DataLayerManager {
* Should be called when the viewer is being torn down. * Should be called when the viewer is being torn down.
*/ */
async destroyAll() { async destroyAll() {
this._layerPanel?.destroy();
this._layerPanel = null;
this._toggleContainer = null;
for (const layerId of [...this.layers.keys()]) { for (const layerId of [...this.layers.keys()]) {
await this.destroyLayer(layerId); await this.destroyLayer(layerId);
} }
@@ -2019,98 +1967,6 @@ export class DataLayerManager {
this._renderToggles(); this._renderToggles();
} }
_renderToggles() {
if (!this._toggleContainer) return;
this._toggleContainer.innerHTML = '';
for (const layer of this.getAll()) {
if (!layer.showInTogglePanel) continue;
const row = document.createElement('div');
row.className = 'data-toggle-row';
row.dataset.layerId = layer.id;
const topRow = document.createElement('div');
topRow.className = 'data-toggle-top';
const left = document.createElement('div');
left.className = 'data-toggle-left';
left.innerHTML = `<span class="data-icon">${layer.icon}</span><span class="data-name">${layer.name}</span>`;
const right = document.createElement('div');
right.className = 'data-toggle-right';
const count = document.createElement('span');
count.className = 'data-count';
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
this._syncToggleButton(toggle, layer);
toggle.addEventListener('click', async () => {
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
// the lifecycle control focusable while it is busy, and enforce the
// same single-flight interaction contract through ARIA instead.
if (toggle.getAttribute('aria-disabled') === 'true') return;
toggle.setAttribute('aria-disabled', 'true');
toggle.setAttribute('aria-busy', 'true');
try {
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
} catch (error) {
console.warn(`[Data] ${layer.id} toggle error:`, error);
} finally {
const current = this.getAll().find(({ id }) => id === layer.id);
if (current) this._syncToggleButton(toggle, current);
}
});
right.appendChild(count);
right.appendChild(toggle);
topRow.appendChild(left);
topRow.appendChild(right);
const bottomRow = document.createElement('div');
bottomRow.className = 'data-toggle-meta';
bottomRow.textContent = this._buildMetaText(layer);
row.appendChild(topRow);
row.appendChild(bottomRow);
// Optional per-layer sub-controls (chips + color legend). The click
// listener is delegated and attached once here, so it survives
// _refreshTogglePanel — which only rewrites the container's contents.
const rowModule = this.layers.get(layer.id)?.module;
if (typeof rowModule?.getRowControls === 'function') {
// A layer whose controls settle asynchronously (a chunked catalog load
// that can also fail) pushes a re-render through this; nothing else
// would repaint the row before its next scheduled refresh.
rowModule.setRowControlsListener?.(() => this._refreshTogglePanel());
const controls = document.createElement('div');
controls.className = 'data-toggle-controls';
controls.addEventListener('click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Re-read the live descriptor rather than trusting the rendered
// chip, so a stale row can never apply an inverted toggle.
const chip = this._rowControlsFor(layer.id)?.chips
?.find((entry) => entry.id === button.dataset.chipId);
if (chip?.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
this._syncRowControls(controls, layer);
}
this._toggleContainer.appendChild(row);
}
}
/**
* Read a layer's optional row-control descriptor, tolerating a throw so one
* misbehaving layer cannot blank the whole panel. Resolved from the registry
* rather than the `getAll()` projection, which deliberately omits `module`.
* @param {string} layerId Registered layer id.
* @returns {{ chips?: Array<object>, legend?: Array<object> }|null} Descriptor.
*/
_rowControlsFor(layerId) { _rowControlsFor(layerId) {
const module = this.layers.get(layerId)?.module; const module = this.layers.get(layerId)?.module;
if (typeof module?.getRowControls !== 'function') return null; if (typeof module?.getRowControls !== 'function') return null;
@@ -2122,186 +1978,25 @@ export class DataLayerManager {
} }
} }
/** _getLayerPanel() {
* Render a layer's row chips and color legend, and keep the whole block if (!this._layerPanel) this._layerPanel = new LayerPanel({
* hidden while the layer is off (or while a dependency owner has surrendered getLayers: () => this.getAll(),
* it) so a quiet row stays quiet. isEnabled: (id) => this.isEnabled(id),
* setEnabled: (id, enabled, options) => this.setEnabled(id, enabled, options),
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than setLayerParams: (id, params, options) => this.setLayerParams(id, params, options),
* rebuilt: this runs on every panel refresh — including the one the chip's getRowControls: (id) => this._rowControlsFor(id),
* own click triggers — and replacing the node would drop keyboard focus hasRowControls: (id) => typeof this.layers.get(id)?.module?.getRowControls === 'function',
* mid-interaction. Legend entries hold no focus and no listeners, so they subscribeRowControls: (id, listener) => {
* are replaced freely. const module = this.layers.get(id)?.module;
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node. module?.setRowControlsListener?.(listener);
* @param {object} layer Registered layer entry. return () => module?.setRowControlsListener?.(null);
*/ },
_syncRowControls(container, layer) { onHiddenRefresh: () => { this._panelRefreshPendingOnVisible = true; },
if (!container) return; });
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null; return this._layerPanel;
const chips = controls?.chips || [];
const legend = controls?.legend || [];
container.hidden = chips.length === 0 && legend.length === 0;
for (const node of [...container.children]) {
if (String(node.className).split(/\s+/).includes('data-toggle-legend-item')) node.remove();
}
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
}
for (const chip of chips) {
let button = stale.get(chip.id);
stale.delete(chip.id);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.dataset.chipId = chip.id;
container.appendChild(button);
}
const state = chip.state || (chip.active ? 'active' : 'idle');
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
if (button.textContent !== chip.label) button.textContent = chip.label;
button.title = chip.title || '';
button.disabled = Boolean(chip.disabled);
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
}
for (const node of stale.values()) node.remove();
for (const item of legend) {
const entry = document.createElement('span');
entry.className = 'data-toggle-legend-item';
if (item.blurb) entry.title = item.blurb;
const swatch = document.createElement('span');
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
const text = document.createElement('span');
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text);
container.appendChild(entry);
}
}
_refreshTogglePanel() {
if (!this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
// refresh on return. (perf wave 2)
if (typeof document !== 'undefined' && document.hidden) {
this._panelRefreshPendingOnVisible = true;
return;
}
for (const layer of this.getAll()) {
const row = this._toggleContainer.querySelector(`[data-layer-id="${layer.id}"]`);
if (!row) continue;
const btn = row.querySelector('.data-toggle-btn');
if (btn) {
this._syncToggleButton(btn, layer);
}
const count = row.querySelector('.data-count');
if (count) {
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
}
const meta = row.querySelector('.data-toggle-meta');
if (meta) {
meta.textContent = this._buildMetaText(layer);
}
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
}
}
_buildMetaText(layer) {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = FEED_STATE_LABELS[feedState];
const source = stats.source || layer.source;
const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
return `${lifecycleState.toUpperCase()} · ${source}`;
}
if (layer.lifecycleUncertain) {
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
}
const presentedError = stats.error || stats.lastError || stats.managerRefreshError;
if (presentedError) {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`;
}
return `${stateLabel} · ${source} · ${presentedError}`;
}
// A guidance status carries its prompt in `statusMessage`, not `error`, so
// the row still tells the operator what to do without reporting a fault.
if (GUIDANCE_STATUSES.includes(String(stats.status || '').toLowerCase())
&& typeof stats.statusMessage === 'string' && stats.statusMessage.trim()) {
return `${source} · ${stats.statusMessage.trim()}`;
}
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
if (stats.loading) {
const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: 'loading...';
return `${source} · ${loadingLabel}`;
}
if (feedState === 'fallback') {
const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: (stats.coverage || ago);
return `${stateLabel} · ${source} · ${detail}`;
}
if (feedState === 'stale') {
const retry = typeof stats.retryInSec === 'number' && stats.retryInSec > 0
? ` · retrying in ${stats.retryInSec}s`
: '';
return `${stateLabel} · ${source} · ${ago}${retry}`;
}
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return `${source} · ${stats.loadingLabel.trim()}`;
}
return `${source} · ${ago}`;
}
_syncToggleButton(button, layer) {
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling';
const uncertain = Boolean(layer.lifecycleUncertain);
button.classList.toggle('active', layer.enabled);
button.classList.toggle('transitioning', transitioning);
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
button.classList.toggle('lifecycle-uncertain', uncertain);
for (const state of Object.keys(FEED_STATE_LABELS)) {
button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state);
}
button.dataset.feedState = transitioning
? layer.lifecycleState
: (uncertain ? 'uncertain' : feedState);
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
}
_formatCount(n) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}
_timeAgo(timestamp) {
const diff = Math.floor((Date.now() - timestamp) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
} }
_renderToggles() { this._getLayerPanel().mount(this._toggleContainer); }
_refreshTogglePanel() { this._layerPanel?._refreshTogglePanel(); }
_buildMetaText(layer) { return this._getLayerPanel()._buildMetaText(layer); }
_syncToggleButton(button, layer) { this._getLayerPanel()._syncToggleButton(button, layer); }
} }
+35
View File
@@ -105,6 +105,7 @@ test('renders ordinary layer rows without recreating a panel-hidden coordinator'
}, },
appendChild(child) { this.children.push(child); return child; }, appendChild(child) { this.children.push(child); return child; },
addEventListener() {}, addEventListener() {},
removeEventListener() {},
setAttribute(name, value) { this.attributes[name] = String(value); }, setAttribute(name, value) { this.attributes[name] = String(value); },
querySelector(selector) { querySelector(selector) {
if (selector.startsWith('[data-layer-id="')) { if (selector.startsWith('[data-layer-id="')) {
@@ -2756,6 +2757,7 @@ function makeControlElement() {
}, },
focus() { if (globalThis.document) globalThis.document.activeElement = this; }, focus() { if (globalThis.document) globalThis.document.activeElement = this; },
addEventListener(name, handler) { this.listeners[name] = handler; }, addEventListener(name, handler) { this.listeners[name] = handler; },
removeEventListener(name, handler) { if (this.listeners[name] === handler) delete this.listeners[name]; },
setAttribute(name, value) { this.attributes[name] = String(value); }, setAttribute(name, value) { this.attributes[name] = String(value); },
getAttribute(name) { return this.attributes[name] ?? null; }, getAttribute(name) { return this.attributes[name] ?? null; },
closest(selector) { closest(selector) {
@@ -3267,3 +3269,36 @@ test('layer metadata shows a guidance prompt without reporting it as a fault', (
assert.match(text, /Zoom in to search mapped installations$/); assert.match(text, /Zoom in to search mapped installations$/);
assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/); assert.doesNotMatch(text, /UNAVAILABLE|DEGRADED/);
}); });
test('panel remount releases old listeners and destruction revokes retained controls', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('lifecycle-panel', { updateInterval: -1 });
let rowListener;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { rowListener = listener; };
manager.register(layer.module);
try {
const first = makeControlElement();
manager.buildTogglePanel(first);
const oldButton = first.querySelector('.data-toggle-btn');
const retainedClick = oldButton.listeners.click;
const second = makeControlElement();
manager.buildTogglePanel(second);
assert.equal(oldButton.listeners.click, undefined);
await retainedClick();
assert.equal(layer.calls.enable, 0, 'a revoked generation cannot issue actions');
const currentButton = second.querySelector('.data-toggle-btn');
assert.equal(typeof currentButton.listeners.click, 'function');
assert.equal(typeof rowListener, 'function');
await manager.destroyAll();
assert.equal(currentButton.listeners.click, undefined);
assert.equal(rowListener, null);
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
+176 -74
View File
@@ -16,21 +16,34 @@ function finiteCount(value) {
/** Normalize one manager layer into a small loading-feedback record. */ /** Normalize one manager layer into a small loading-feedback record. */
export function normalizeLayerLoading(layer = {}) { export function normalizeLayerLoading(layer = {}) {
const stats = layer.stats || {}; const stats = layer.stats || {};
const lifecycleState = String(layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled')); const lifecycleState = String(
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'),
);
const status = String(stats.status || '').toLowerCase(); const status = String(stats.status || '').toLowerCase();
const disabling = lifecycleState === 'disabling'; const disabling = lifecycleState === 'disabling';
const loading = lifecycleState === 'enabling' || disabling || stats.loading === true || stats.refreshing === true; const loading =
lifecycleState === 'enabling' ||
disabling ||
stats.loading === true ||
stats.refreshing === true;
const count = finiteCount(stats.count); const count = finiteCount(stats.count);
const stoppingInstallations = layer.id === 'military-installations' && disabling; const stoppingInstallations =
layer.id === 'military-installations' && disabling;
// Guidance statuses ask the user to act (zoom in, run a search). They are // Guidance statuses ask the user to act (zoom in, run a search). They are
// normal operation, never a batch failure — mirrors layerFeedState's carve-out // normal operation, never a batch failure — mirrors layerFeedState's carve-out
// so a prompt stored alongside the status cannot turn the chip red. // so a prompt stored alongside the status cannot turn the chip red.
const guidance = GUIDANCE_STATUSES.includes(status); const guidance = GUIDANCE_STATUSES.includes(status);
const error = stoppingInstallations ? null const error = stoppingInstallations
: (!guidance && stats.error) || stats.lastError || stats.managerRefreshError || null; ? null
const unavailable = !stoppingInstallations && (stats.unavailable === true : (!guidance && stats.error) ||
|| stats.available === false stats.lastError ||
|| ['unavailable', 'offline', 'down', 'error'].includes(status)); stats.managerRefreshError ||
null;
const unavailable =
!stoppingInstallations &&
(stats.unavailable === true ||
stats.available === false ||
['unavailable', 'offline', 'down', 'error'].includes(status));
const keyRequired = stats.keyRequired === true || stats.missingKey === true; const keyRequired = stats.keyRequired === true || stats.missingKey === true;
const degraded = stats.degraded === true || Boolean(error); const degraded = stats.degraded === true || Boolean(error);
const accepted = Boolean(stats.lastUpdate) || count > 0; const accepted = Boolean(stats.lastUpdate) || count > 0;
@@ -39,7 +52,8 @@ export function normalizeLayerLoading(layer = {}) {
label: String(layer.name || layer.id || 'Layer'), label: String(layer.name || layer.id || 'Layer'),
loading, loading,
disabling, disabling,
refresh: loading && layer.enabled && (stats.refreshing === true || accepted), refresh:
loading && layer.enabled && (stats.refreshing === true || accepted),
lifecycleState, lifecycleState,
count, count,
accepted, accepted,
@@ -47,9 +61,15 @@ export function normalizeLayerLoading(layer = {}) {
unavailable, unavailable,
keyRequired, keyRequired,
degraded, degraded,
installationRetry: layer.id === 'military-installations' && layer.enabled && !disabling installationRetry:
? { retryAt: Number(stats.retryAt) || 0, retrying: stats.retrying === true, layer.id === 'military-installations' && layer.enabled && !disabling
failureReason: stats.failureReason, loading, status: stats.status } ? {
retryAt: Number(stats.retryAt) || 0,
retrying: stats.retrying === true,
failureReason: stats.failureReason,
loading,
status: stats.status,
}
: null, : null,
}; };
} }
@@ -57,8 +77,11 @@ export function normalizeLayerLoading(layer = {}) {
function terminalFromParticipantStats(summary, participantIds) { function terminalFromParticipantStats(summary, participantIds) {
if (!participantIds?.length) return null; if (!participantIds?.length) return null;
const participants = new Set(participantIds); const participants = new Set(participantIds);
return summary.records.some((record) => participants.has(record.id) return summary.records.some(
&& (record.error || record.unavailable || record.keyRequired)) (record) =>
participants.has(record.id) &&
(record.error || record.unavailable || record.keyRequired),
)
? 'error' ? 'error'
: null; : null;
} }
@@ -67,13 +90,17 @@ function terminalFromParticipantStats(summary, participantIds) {
export function aggregateLayerLoading(layers = []) { export function aggregateLayerLoading(layers = []) {
const records = layers.map(normalizeLayerLoading); const records = layers.map(normalizeLayerLoading);
const active = records.filter((record) => record.loading); const active = records.filter((record) => record.loading);
const disabling = active.length > 0 && active.every((record) => record.disabling); const disabling =
active.length > 0 && active.every((record) => record.disabling);
return { return {
records, records,
active, active,
activeIds: active.map((record) => record.id), activeIds: active.map((record) => record.id),
disabling, disabling,
refresh: !disabling && active.length > 0 && active.every((record) => record.refresh), refresh:
!disabling &&
active.length > 0 &&
active.every((record) => record.refresh),
}; };
} }
@@ -92,11 +119,11 @@ export function createLoadingFeedbackState() {
} }
/** Create a top-center status notice, optionally persistent until explicitly cleared. */ /** Create a top-center status notice, optionally persistent until explicitly cleared. */
export function createGlobalStatusNotice(message, nowMs = 0, { export function createGlobalStatusNotice(
state = 'error', message,
detail = '', nowMs = 0,
persistent = false, { state = 'error', detail = '', persistent = false } = {},
} = {}) { ) {
const label = String(message || '').trim(); const label = String(message || '').trim();
if (!label) return null; if (!label) return null;
return { return {
@@ -117,7 +144,9 @@ export function presentGlobalStatusNotice(notice, nowMs = 0) {
const now = Number.isFinite(nowMs) ? nowMs : 0; const now = Number.isFinite(nowMs) ? nowMs : 0;
if (!notice?.label) return null; if (!notice?.label) return null;
if (!notice.persistent && !Number.isFinite(notice.hideAt)) { if (!notice.persistent && !Number.isFinite(notice.hideAt)) {
notice.hideAt = now + (Number.isFinite(notice.dwellMs) notice.hideAt =
now +
(Number.isFinite(notice.dwellMs)
? notice.dwellMs ? notice.dwellMs
: LOADING_FAILURE_DWELL_MS); : LOADING_FAILURE_DWELL_MS);
} }
@@ -130,10 +159,16 @@ export function presentGlobalStatusNotice(notice, nowMs = 0) {
} }
/** Whether deferred notice work still owns the current presentation epoch. */ /** Whether deferred notice work still owns the current presentation epoch. */
export function canPresentDeferredStatusNotice(expectedGeneration, currentGeneration, disposed = false) { export function canPresentDeferredStatusNotice(
return !disposed expectedGeneration,
&& Number.isSafeInteger(expectedGeneration) currentGeneration,
&& expectedGeneration === currentGeneration; disposed = false,
) {
return (
!disposed &&
Number.isSafeInteger(expectedGeneration) &&
expectedGeneration === currentGeneration
);
} }
/** /**
@@ -141,9 +176,19 @@ export function canPresentDeferredStatusNotice(expectedGeneration, currentGenera
* hide a terminal manager failure. Failure dwell starts when the manager * hide a terminal manager failure. Failure dwell starts when the manager
* reports it, so it must remain the highest-priority presentation while live. * reports it, so it must remain the highest-priority presentation while live.
*/ */
export function presentGlobalLoadingStatus(notice, loadingState, summary, nowMs = 0) { export function presentGlobalLoadingStatus(
const loadingPresentation = presentLoadingFeedback(loadingState, summary, nowMs); notice,
if (['error', 'retry'].includes(loadingPresentation?.state)) return loadingPresentation; loadingState,
summary,
nowMs = 0,
) {
const loadingPresentation = presentLoadingFeedback(
loadingState,
summary,
nowMs,
);
if (['error', 'retry'].includes(loadingPresentation?.state))
return loadingPresentation;
return presentGlobalStatusNotice(notice, nowMs) || loadingPresentation; return presentGlobalStatusNotice(notice, nowMs) || loadingPresentation;
} }
@@ -162,11 +207,11 @@ export function createTrafficSyncFeedbackState() {
* Reduce one sampled Street Traffic status without extending completion on * Reduce one sampled Street Traffic status without extending completion on
* every animation-loop poll. Coverage describes accepted data, not work. * every animation-loop poll. Coverage describes accepted data, not work.
*/ */
export function reduceTrafficSyncFeedback(previous, { export function reduceTrafficSyncFeedback(
enabled = false, previous,
stats = {}, { enabled = false, stats = {}, forceShow = false } = {},
forceShow = false, nowMs = 0,
} = {}, nowMs = 0) { ) {
const state = previous || createTrafficSyncFeedbackState(); const state = previous || createTrafficSyncFeedbackState();
const now = Number.isFinite(nowMs) ? nowMs : 0; const now = Number.isFinite(nowMs) ? nowMs : 0;
if (!enabled) return createTrafficSyncFeedbackState(); if (!enabled) return createTrafficSyncFeedbackState();
@@ -174,10 +219,13 @@ export function reduceTrafficSyncFeedback(previous, {
const hasProgress = Number.isFinite(stats.phaseProgressPct); const hasProgress = Number.isFinite(stats.phaseProgressPct);
const progressPct = hasProgress const progressPct = hasProgress
? Math.max(0, Math.min(100, Math.round(stats.phaseProgressPct))) ? Math.max(0, Math.min(100, Math.round(stats.phaseProgressPct)))
: (stats.loading ? 1 : 100); : stats.loading
const busy = stats.loading === true ? 1
|| stats.worldJumping === true : 100;
|| (hasProgress && (progressPct < 100 || (stats.prewarmQueueDepth ?? 0) > 0)); const busy =
stats.loading === true ||
stats.worldJumping === true ||
(hasProgress && (progressPct < 100 || (stats.prewarmQueueDepth ?? 0) > 0));
const label = String(stats.phaseLabel || stats.loadingLabel || '').trim(); const label = String(stats.phaseLabel || stats.loadingLabel || '').trim();
if (busy) { if (busy) {
@@ -193,13 +241,13 @@ export function reduceTrafficSyncFeedback(previous, {
}; };
} }
const existingConfirmation = state.confirmationUntil > now const existingConfirmation =
? state.confirmationUntil state.confirmationUntil > now ? state.confirmationUntil : 0;
: 0; const confirmationUntil =
const confirmationUntil = existingConfirmation || (state.busy || forceShow existingConfirmation ||
? now + TRAFFIC_SYNC_CONFIRM_MS (state.busy || forceShow ? now + TRAFFIC_SYNC_CONFIRM_MS : 0);
: 0); const visible =
const visible = confirmationUntil > now && progressPct >= 100 && Boolean(label); confirmationUntil > now && progressPct >= 100 && Boolean(label);
return { return {
busy: false, busy: false,
visible, visible,
@@ -217,7 +265,8 @@ export function reduceTrafficSyncFeedback(previous, {
function terminalFromEvent(event) { function terminalFromEvent(event) {
const type = String(event?.type || ''); const type = String(event?.type || '');
if (type === 'visibility-failed' || type === 'refresh-failed' || event?.error) return 'error'; if (type === 'visibility-failed' || type === 'refresh-failed' || event?.error)
return 'error';
if (type === 'visibility-cancelled' || event?.cancelled) return 'cancelled'; if (type === 'visibility-cancelled' || event?.cancelled) return 'cancelled';
if (type === 'visibility' || type === 'refresh') return 'complete'; if (type === 'visibility' || type === 'refresh') return 'complete';
return null; return null;
@@ -238,7 +287,9 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
const beginning = state.phase !== 'loading'; const beginning = state.phase !== 'loading';
const startedAt = beginning ? now : state.startedAt; const startedAt = beginning ? now : state.startedAt;
const priorParticipants = beginning ? [] : state.activeIds; const priorParticipants = beginning ? [] : state.activeIds;
const activeIds = [...new Set([...priorParticipants, ...summary.activeIds])]; const activeIds = [
...new Set([...priorParticipants, ...summary.activeIds]),
];
const eventLayerId = String(event?.layerId || ''); const eventLayerId = String(event?.layerId || '');
const eventParticipates = eventLayerId && activeIds.includes(eventLayerId); const eventParticipates = eventLayerId && activeIds.includes(eventLayerId);
const batchOutcome = mergeTerminalOutcome( const batchOutcome = mergeTerminalOutcome(
@@ -257,18 +308,28 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
activeIds, activeIds,
batchOutcome, batchOutcome,
terminal: null, terminal: null,
operation: summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading', operation: summary.disabling
failedEventIds: [...new Set([ ? 'disabling'
: summary.refresh
? 'refresh'
: 'loading',
failedEventIds: [
...new Set([
...(beginning ? [] : state.failedEventIds || []), ...(beginning ? [] : state.failedEventIds || []),
...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []), ...(eventParticipates && terminalFromEvent(event) === 'error'
])], ? [eventLayerId]
: []),
]),
],
}; };
} }
if (state.phase === 'loading') { if (state.phase === 'loading') {
const eventLayerId = String(event?.layerId || ''); const eventLayerId = String(event?.layerId || '');
const eventParticipates = eventLayerId && state.activeIds.includes(eventLayerId); const eventParticipates =
const terminal = mergeTerminalOutcome( eventLayerId && state.activeIds.includes(eventLayerId);
const terminal =
mergeTerminalOutcome(
mergeTerminalOutcome( mergeTerminalOutcome(
state.batchOutcome, state.batchOutcome,
terminalFromParticipantStats(summary, state.activeIds), terminalFromParticipantStats(summary, state.activeIds),
@@ -276,8 +337,12 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
eventParticipates ? terminalFromEvent(event) : null, eventParticipates ? terminalFromEvent(event) : null,
) || 'complete'; ) || 'complete';
const wasVisible = state.visible || now >= state.showAt; const wasVisible = state.visible || now >= state.showAt;
if (!wasVisible && terminal === 'complete') return createLoadingFeedbackState(); if (!wasVisible && terminal === 'complete')
const dwell = terminal === 'error' ? LOADING_FAILURE_DWELL_MS : LOADING_TERMINAL_DWELL_MS; return createLoadingFeedbackState();
const dwell =
terminal === 'error'
? LOADING_FAILURE_DWELL_MS
: LOADING_TERMINAL_DWELL_MS;
return { return {
...state, ...state,
phase: 'terminal', phase: 'terminal',
@@ -285,10 +350,14 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
hideAt: now + dwell, hideAt: now + dwell,
batchOutcome: terminal, batchOutcome: terminal,
terminal, terminal,
failedEventIds: [...new Set([ failedEventIds: [
...new Set([
...(state.failedEventIds || []), ...(state.failedEventIds || []),
...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []), ...(eventParticipates && terminalFromEvent(event) === 'error'
])], ? [eventLayerId]
: []),
]),
],
}; };
} }
@@ -298,11 +367,17 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
/** Build the user-facing status copy for the current loading state. */ /** Build the user-facing status copy for the current loading state. */
export function presentLoadingFeedback(state, summary, nowMs) { export function presentLoadingFeedback(state, summary, nowMs) {
const site = summary.records.find(record => record.installationRetry?.retryAt > 0); const site = summary.records.find(
const otherFailure = summary.records.some(record => record.id !== 'military-installations' (record) => record.installationRetry?.retryAt > 0,
&& (state?.activeIds || []).includes(record.id) );
&& (record.error || record.unavailable || record.keyRequired)) const otherFailure =
|| (state?.failedEventIds || []).some(id => id !== 'military-installations'); summary.records.some(
(record) =>
record.id !== 'military-installations' &&
(state?.activeIds || []).includes(record.id) &&
(record.error || record.unavailable || record.keyRequired),
) ||
(state?.failedEventIds || []).some((id) => id !== 'military-installations');
// Keep the actual retry visible between attempts, without hiding another // Keep the actual retry visible between attempts, without hiding another
// participant's failure or pretending that a scheduled retry is fetching. // participant's failure or pretending that a scheduled retry is fetching.
if (site && !summary.active.length && !otherFailure) { if (site && !summary.active.length && !otherFailure) {
@@ -312,28 +387,55 @@ export function presentLoadingFeedback(state, summary, nowMs) {
} }
if (!state?.visible) return null; if (!state?.visible) return null;
if (state.phase === 'terminal') { if (state.phase === 'terminal') {
const labels = { complete: 'LOAD COMPLETE', cancelled: 'LOAD CANCELLED', error: 'LOAD FAILED' }; const labels = {
const label = state.operation === 'disabling' && state.terminal === 'complete' complete: 'LOAD COMPLETE',
cancelled: 'LOAD CANCELLED',
error: 'LOAD FAILED',
};
const label =
state.operation === 'disabling' && state.terminal === 'complete'
? 'LIVE DATA OFF' ? 'LIVE DATA OFF'
: state.terminal === 'complete' && state.activeIds?.length === 1 && state.activeIds[0] === 'military-installations' : state.terminal === 'complete' &&
? 'MAPPED SITES LOADED' : labels[state.terminal] || 'LOAD COMPLETE'; state.activeIds?.length === 1 &&
state.activeIds[0] === 'military-installations'
? 'MAPPED SITES LOADED'
: labels[state.terminal] || 'LOAD COMPLETE';
return { state: state.terminal, label, detail: '' }; return { state: state.terminal, label, detail: '' };
} }
const active = summary.active; const active = summary.active;
if (active.length === 1 && active[0].installationRetry && !summary.disabling) { if (
return { state: 'loading', label: active[0].installationRetry.retrying active.length === 1 &&
? 'RETRYING MAPPED SITES' : 'FETCHING MAPPED SITES', detail: 'OpenStreetMap · Overpass' }; active[0].installationRetry &&
!summary.disabling
) {
return {
state: 'loading',
label: active[0].installationRetry.retrying
? 'RETRYING MAPPED SITES'
: 'FETCHING MAPPED SITES',
detail: 'OpenStreetMap · Overpass',
};
} }
const elapsed = Math.max(0, nowMs - state.startedAt); const elapsed = Math.max(0, nowMs - state.startedAt);
const label = summary.disabling const label = summary.disabling
? 'TURNING OFF LIVE DATA' ? 'TURNING OFF LIVE DATA'
: summary.refresh ? 'REFRESHING LIVE DATA' : 'LOADING LIVE DATA'; : summary.refresh
const names = active.slice(0, 2).map((record) => record.label).join(' · '); ? 'REFRESHING LIVE DATA'
: 'LOADING LIVE DATA';
const names = active
.slice(0, 2)
.map((record) => record.label)
.join(' · ');
const suffix = active.length > 2 ? ` +${active.length - 2}` : ''; const suffix = active.length > 2 ? ` +${active.length - 2}` : '';
return { return {
state: elapsed >= LOADING_LONG_THRESHOLD_MS state:
elapsed >= LOADING_LONG_THRESHOLD_MS
? 'long' ? 'long'
: summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading', : summary.disabling
? 'disabling'
: summary.refresh
? 'refresh'
: 'loading',
label, label,
detail: `${names}${suffix}`, detail: `${names}${suffix}`,
}; };
+1
View File
@@ -692,6 +692,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/); assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
assert.match(synchronous, /this\._visualEffects\.stop\(\)/); assert.match(synchronous, /this\._visualEffects\.stop\(\)/);
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/); assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/); assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/); assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
}); });
+7 -13
View File
@@ -1,3 +1,4 @@
import { bindClearLayersControl } from './ui/layers.js';
import { createMapSourceControls } from './ui/mapSource.js'; import { createMapSourceControls } from './ui/mapSource.js';
import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js'; import { VisualEffects, STYLES, GLOBAL_POST_DEFAULTS, STYLE_PRESET_DEFAULTS, MILITARY_DETECTION_PRESET } from './ui/effects.js';
import { bindDisplayControls } from './ui/displayControls.js'; import { bindDisplayControls } from './ui/displayControls.js';
@@ -2087,7 +2088,6 @@ export class StyleManager {
this._globeResetHandler = null; this._globeResetHandler = null;
this._clearSelectedLayersPromise = null; this._clearSelectedLayersPromise = null;
this._clearSelectedLayersManagerPromise = null; this._clearSelectedLayersManagerPromise = null;
this._clearSelectedLayersHandler = null;
this._dataManager = null; this._dataManager = null;
this._cctvUnsubscribe = null; this._cctvUnsubscribe = null;
this._radioUnsubscribe = null; this._radioUnsubscribe = null;
@@ -8900,8 +8900,8 @@ export class StyleManager {
/** Wire the top-center action that clears only manager-owned data layers. */ /** Wire the top-center action that clears only manager-owned data layers. */
_initClearSelectedLayersButton() { _initClearSelectedLayersButton() {
if (!this._clearSelectedLayersBtn) return; if (!this._clearSelectedLayersBtn) return;
this._clearSelectedLayersHandler = () => { void this.clearSelectedLayers(); }; this._clearLayersControl?.destroy();
this._clearSelectedLayersBtn.addEventListener('click', this._clearSelectedLayersHandler); this._clearLayersControl = bindClearLayersControl(this._clearSelectedLayersBtn, () => this.clearSelectedLayers());
} }
/** /**
@@ -8928,9 +8928,7 @@ export class StyleManager {
this._preservePanelStateDuringLayerClear = true; this._preservePanelStateDuringLayerClear = true;
this._syncContextModeButtons(); this._syncContextModeButtons();
this._userFacingContextNotificationTokens.add(notificationToken); this._userFacingContextNotificationTokens.add(notificationToken);
this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'true'); this._clearLayersControl?.setBusy(true);
this._clearSelectedLayersBtn.setAttribute('aria-busy', 'true');
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clearing selected data layers');
const managerOperation = this._dataManager.clearSelectedLayers({ const managerOperation = this._dataManager.clearSelectedLayers({
origin: 'user', origin: 'user',
@@ -8962,9 +8960,7 @@ export class StyleManager {
this._contextModeChanging = false; this._contextModeChanging = false;
this._syncContextModeButtons(); this._syncContextModeButtons();
} }
this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'false'); this._clearLayersControl?.setBusy(false);
this._clearSelectedLayersBtn.setAttribute('aria-busy', 'false');
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clear selected data layers');
this._preservePanelStateDuringLayerClear = false; this._preservePanelStateDuringLayerClear = false;
this._clearSelectedLayersManagerPromise = null; this._clearSelectedLayersManagerPromise = null;
this._clearSelectedLayersPromise = null; this._clearSelectedLayersPromise = null;
@@ -9377,6 +9373,7 @@ export class StyleManager {
this._applicationShortcuts?.destroy(); this._applicationShortcuts?.destroy();
this._displayControls?.destroy(); this._displayControls?.destroy();
this._mapSourceControls?.destroy(); this._mapSourceControls?.destroy();
this._clearLayersControl?.destroy();
this._visualEffects.stop(); this._visualEffects.stop();
this._styleParameters?.destroy(); this._styleParameters?.destroy();
for (const control of this._panelDisclosureControls || []) control.destroy(); for (const control of this._panelDisclosureControls || []) control.destroy();
@@ -9466,10 +9463,7 @@ export class StyleManager {
this._cockpitResetGlobeBtn?.removeEventListener('click', this._globeResetHandler); this._cockpitResetGlobeBtn?.removeEventListener('click', this._globeResetHandler);
this._globeResetHandler = null; this._globeResetHandler = null;
} }
if (this._clearSelectedLayersBtn && this._clearSelectedLayersHandler) {
this._clearSelectedLayersBtn.removeEventListener('click', this._clearSelectedLayersHandler);
this._clearSelectedLayersHandler = null;
}
this._cctvUnsubscribe?.(); this._cctvUnsubscribe?.();
this._cctvUnsubscribe = null; this._cctvUnsubscribe = null;
this._commandDockTrayObserver?.disconnect?.(); this._commandDockTrayObserver?.disconnect?.();
+23
View File
@@ -0,0 +1,23 @@
/** Keep the clear action focusable while busy; the caller owns its transaction. */
export function bindClearLayersControl(button, clear) {
let destroyed = false;
const click = () => {
if (!destroyed) void clear();
};
button?.addEventListener('click', click);
return {
setBusy(busy) {
if (destroyed || !button) return;
button.setAttribute('aria-disabled', String(busy));
button.setAttribute('aria-busy', String(busy));
button.setAttribute(
'aria-label',
busy ? 'Clearing selected data layers' : 'Clear selected data layers',
);
},
destroy() {
destroyed = true;
button?.removeEventListener('click', click);
},
};
}
+432
View File
@@ -0,0 +1,432 @@
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
/**
* Normalize heterogeneous layer stats into one honest control-chip state.
* @param {object|null} stats Layer getStats() result.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state.
*/
export function layerFeedState(stats = {}) {
const state = stats || {};
const status =
typeof state.status === 'string' ? state.status.toLowerCase() : '';
const source = `${state.source || ''} ${state.coverage || ''}`;
const hasExplicitFallback = typeof state.fallback === 'boolean';
const hasPriorData = Number(state.count) > 0 || Boolean(state.lastUpdate);
const presentedError =
state.error || state.lastError || state.managerRefreshError;
if (['unavailable', 'offline', 'down', 'error'].includes(status))
return 'unavailable';
if (
(presentedError ||
state.unavailable === true ||
state.available === false) &&
!hasPriorData &&
!GUIDANCE_STATUSES.includes(status)
) {
return 'unavailable';
}
if (state.loading) return 'loading';
// Guidance states ask the user to act (zoom in, run a search) — normal
// operation, not feed faults. One honesty carve-out: layers keep their
// rendered records through the guidance state, so a genuinely stale cache
// still reads STALE; a guidance prompt alone never reads DEGRADED.
if (GUIDANCE_STATUSES.includes(status)) {
return state.stale ? 'stale' : 'nominal';
}
if (
state.fallback === true ||
status === 'fallback' ||
state.mode === 'sim' ||
/\bfallback\b/i.test(source) ||
(!hasExplicitFallback && /\badsb\.lol\b/i.test(source))
) {
return 'fallback';
}
if (state.stale || status === 'stale') return 'stale';
if (
state.degraded ||
presentedError ||
state.unavailable === true ||
state.available === false
)
return 'degraded';
return 'nominal';
}
/** Layer row presentation over supplied state and actions; no layer imports. */
export class LayerPanel {
constructor({
getLayers,
isEnabled,
setEnabled,
setLayerParams,
getRowControls,
hasRowControls,
subscribeRowControls,
onHiddenRefresh = () => {},
}) {
this.getAll = getLayers;
this.isEnabled = isEnabled;
this.setEnabled = setEnabled;
this.setLayerParams = setLayerParams;
this._rowControlsFor = getRowControls;
this.hasRowControls = hasRowControls;
this.subscribeRowControls = subscribeRowControls;
this.onHiddenRefresh = onHiddenRefresh;
this._generation = 0;
this._removers = [];
this._destroyed = false;
}
mount(container) {
if (this._destroyed) return;
this._releaseBindings();
this._toggleContainer = container;
this._renderToggles();
}
_bind(element, type, listener) {
element.addEventListener(type, listener);
this._removers.push(() => element.removeEventListener(type, listener));
}
_releaseBindings() {
this._generation++;
for (const remove of this._removers.splice(0)) remove();
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._releaseBindings();
this._toggleContainer = null;
}
_renderToggles() {
if (this._destroyed || !this._toggleContainer) return;
this._releaseBindings();
this._toggleContainer.innerHTML = '';
const generation = this._generation;
for (const layer of this.getAll()) {
if (!layer.showInTogglePanel) continue;
const row = document.createElement('div');
row.className = 'data-toggle-row';
row.dataset.layerId = layer.id;
const topRow = document.createElement('div');
topRow.className = 'data-toggle-top';
const left = document.createElement('div');
left.className = 'data-toggle-left';
const icon = document.createElement('span');
icon.className = 'data-icon';
icon.textContent = layer.icon;
const name = document.createElement('span');
name.className = 'data-name';
name.textContent = layer.name;
left.appendChild(icon);
left.appendChild(name);
const right = document.createElement('div');
right.className = 'data-toggle-right';
const count = document.createElement('span');
count.className = 'data-count';
count.textContent = layer.stats.count
? this._formatCount(layer.stats.count)
: '—';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
this._syncToggleButton(toggle, layer);
this._bind(toggle, 'click', async () => {
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
// the lifecycle control focusable while it is busy, and enforce the
// same single-flight interaction contract through ARIA instead.
if (
this._destroyed ||
this._generation !== generation ||
toggle.getAttribute('aria-disabled') === 'true'
)
return;
toggle.setAttribute('aria-disabled', 'true');
toggle.setAttribute('aria-busy', 'true');
try {
await this.setEnabled(layer.id, !this.isEnabled(layer.id), {
origin: 'user',
});
} catch (error) {
console.warn(`[Data] ${layer.id} toggle error:`, error);
} finally {
const current = this.getAll().find(({ id }) => id === layer.id);
if (!this._destroyed && current && this._generation === generation)
this._syncToggleButton(toggle, current);
}
});
right.appendChild(count);
right.appendChild(toggle);
topRow.appendChild(left);
topRow.appendChild(right);
const bottomRow = document.createElement('div');
bottomRow.className = 'data-toggle-meta';
bottomRow.textContent = this._buildMetaText(layer);
row.appendChild(topRow);
row.appendChild(bottomRow);
// Optional per-layer sub-controls (chips + color legend). The click
// listener is delegated and attached once here, so it survives
// _refreshTogglePanel — which only rewrites the container's contents.
if (this.hasRowControls(layer.id)) {
// A layer whose controls settle asynchronously (a chunked catalog load
// that can also fail) pushes a re-render through this; nothing else
// would repaint the row before its next scheduled refresh.
const unsubscribe = this.subscribeRowControls(layer.id, () =>
this._refreshTogglePanel(),
);
if (unsubscribe) this._removers.push(unsubscribe);
const controls = document.createElement('div');
controls.className = 'data-toggle-controls';
this._bind(controls, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Re-read the live descriptor rather than trusting the rendered
// chip, so a stale row can never apply an inverted toggle.
const chip = this._rowControlsFor(layer.id)?.chips?.find(
(entry) => entry.id === button.dataset.chipId,
);
if (chip?.params)
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
this._syncRowControls(controls, layer);
}
this._toggleContainer.appendChild(row);
}
}
/**
* Read a layer's optional row-control descriptor, tolerating a throw so one
* misbehaving layer cannot blank the whole panel. Resolved from the registry
* rather than the `getAll()` projection, which deliberately omits `module`.
* @param {string} layerId Registered layer id.
* @returns {{ chips?: Array<object>, legend?: Array<object> }|null} Descriptor.
*/
/**
* Render a layer's row chips and color legend, and keep the whole block
* hidden while the layer is off (or while a dependency owner has surrendered
* it) so a quiet row stays quiet.
*
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
* rebuilt: this runs on every panel refresh — including the one the chip's
* own click triggers — and replacing the node would drop keyboard focus
* mid-interaction. Legend entries hold no focus and no listeners, so they
* are replaced freely.
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
* @param {object} layer Registered layer entry.
*/
_syncRowControls(container, layer) {
if (!container) return;
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
const chips = controls?.chips || [];
const legend = controls?.legend || [];
container.hidden = chips.length === 0 && legend.length === 0;
for (const node of [...container.children]) {
if (
String(node.className).split(/\s+/).includes('data-toggle-legend-item')
)
node.remove();
}
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
}
for (const chip of chips) {
let button = stale.get(chip.id);
stale.delete(chip.id);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.dataset.chipId = chip.id;
container.appendChild(button);
}
const state = chip.state || (chip.active ? 'active' : 'idle');
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
if (button.textContent !== chip.label) button.textContent = chip.label;
button.title = chip.title || '';
button.disabled = Boolean(chip.disabled);
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
}
for (const node of stale.values()) node.remove();
for (const item of legend) {
const entry = document.createElement('span');
entry.className = 'data-toggle-legend-item';
if (item.blurb) entry.title = item.blurb;
const swatch = document.createElement('span');
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
const text = document.createElement('span');
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text);
container.appendChild(entry);
}
}
_refreshTogglePanel() {
if (this._destroyed || !this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
// refresh on return. (perf wave 2)
if (typeof document !== 'undefined' && document.hidden) {
this.onHiddenRefresh();
return;
}
for (const layer of this.getAll()) {
const row = this._toggleContainer.querySelector(
`[data-layer-id="${layer.id}"]`,
);
if (!row) continue;
const btn = row.querySelector('.data-toggle-btn');
if (btn) {
this._syncToggleButton(btn, layer);
}
const count = row.querySelector('.data-count');
if (count) {
count.textContent = layer.stats.count
? this._formatCount(layer.stats.count)
: '—';
}
const meta = row.querySelector('.data-toggle-meta');
if (meta) {
meta.textContent = this._buildMetaText(layer);
}
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
}
}
_buildMetaText(layer) {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = FEED_STATE_LABELS[feedState];
const source = stats.source || layer.source;
const lifecycleState =
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
return `${lifecycleState.toUpperCase()} · ${source}`;
}
if (layer.lifecycleUncertain) {
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
}
const presentedError =
stats.error || stats.lastError || stats.managerRefreshError;
if (presentedError) {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`;
}
return `${stateLabel} · ${source} · ${presentedError}`;
}
// A guidance status carries its prompt in `statusMessage`, not `error`, so
// the row still tells the operator what to do without reporting a fault.
if (
GUIDANCE_STATUSES.includes(String(stats.status || '').toLowerCase()) &&
typeof stats.statusMessage === 'string' &&
stats.statusMessage.trim()
) {
return `${source} · ${stats.statusMessage.trim()}`;
}
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
if (stats.loading) {
const loadingLabel =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: 'loading...';
return `${source} · ${loadingLabel}`;
}
if (feedState === 'fallback') {
const detail =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: stats.coverage || ago;
return `${stateLabel} · ${source} · ${detail}`;
}
if (feedState === 'stale') {
const retry =
typeof stats.retryInSec === 'number' && stats.retryInSec > 0
? ` · retrying in ${stats.retryInSec}s`
: '';
return `${stateLabel} · ${source} · ${ago}${retry}`;
}
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return `${source} · ${stats.loadingLabel.trim()}`;
}
return `${source} · ${ago}`;
}
_syncToggleButton(button, layer) {
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
const transitioning =
layer.lifecycleState === 'enabling' ||
layer.lifecycleState === 'disabling';
const uncertain = Boolean(layer.lifecycleUncertain);
button.classList.toggle('active', layer.enabled);
button.classList.toggle('transitioning', transitioning);
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
button.classList.toggle('lifecycle-uncertain', uncertain);
for (const state of Object.keys(FEED_STATE_LABELS)) {
button.classList.toggle(
`feed-${state}`,
layer.enabled && !uncertain && feedState === state,
);
}
button.dataset.feedState = transitioning
? layer.lifecycleState
: uncertain
? 'uncertain'
: feedState;
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: uncertain
? 'UNCERTAIN'
: layer.enabled
? FEED_STATE_LABELS[feedState]
: 'OFF';
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
}
_formatCount(n) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}
_timeAgo(timestamp) {
const diff = Math.floor((Date.now() - timestamp) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { LayerPanel, layerFeedState } from './layerPanel.js';
export { bindClearLayersControl } from './clearLayersControl.js';