mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge pull request #395 from bilawalsidhu/refactor/layer-panel
refactor: separate Layers panel presentation and feedback
This commit is contained in:
18 files changed
+897
-436
No files matched your search
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
|
||||
|
||||
## [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.
|
||||
|
||||
- Separate visual effects, presets and animation from Display controls, with explicit stage ownership and teardown.
|
||||
|
||||
@@ -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
|
||||
listeners, and destruction suppresses late completions without owning or destroying
|
||||
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.
|
||||
@@ -1,5 +1,14 @@
|
||||
# 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 controls own chip listeners, source-state subscriptions and selection
|
||||
|
||||
+3
-1
@@ -126,6 +126,8 @@
|
||||
"./ui/display": "./src/ui/displayControls.js",
|
||||
"./ui/effects": "./src/ui/effects.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"
|
||||
}
|
||||
}
|
||||
@@ -153,5 +153,11 @@
|
||||
"src/ui/mapSourceControls.js",
|
||||
"src/ui/mapSourceControls.test.mjs",
|
||||
"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"
|
||||
]
|
||||
@@ -325,5 +325,16 @@
|
||||
"src/keySetupCore.mjs"
|
||||
],
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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', () => {
|
||||
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-flights-btn"/);
|
||||
assert.match(html, /id="global-context-missions-btn"/);
|
||||
|
||||
@@ -157,5 +157,8 @@ test('Context async action buttons remain focused while busy', () => {
|
||||
assert.match(radioSync, new RegExp(`${name}\\.disabled = false`));
|
||||
}
|
||||
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*=/);
|
||||
});
|
||||
@@ -5,14 +5,17 @@ export function installationFeedback(stats = {}, now = Date.now()) {
|
||||
timeout: 'Overpass timed out',
|
||||
query_failed: 'Overpass could not complete the query',
|
||||
};
|
||||
const reason = reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
if (stats.loading) return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
|
||||
const reason =
|
||||
reasons[stats.failureReason] || 'Overpass temporarily unavailable';
|
||||
if (stats.loading)
|
||||
return stats.retrying ? 'Retrying mapped sites…' : 'Fetching mapped sites…';
|
||||
if (stats.retryAt > 0) {
|
||||
const seconds = Math.max(0, Math.ceil((stats.retryAt - now) / 1000));
|
||||
return `${reason} — ${seconds ? `retrying in ${seconds}s` : 'retry pending'}`;
|
||||
}
|
||||
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.status === 'idle') return 'Mapped sites not loaded';
|
||||
return 'Mapped sites loaded';
|
||||
|
||||
+25
-330
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
function cloneLayerParams(value) {
|
||||
if (Array.isArray(value)) return value.map(cloneLayerParams);
|
||||
@@ -11,15 +12,6 @@ function cloneLayerParams(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 VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
||||
'enabled-only',
|
||||
@@ -65,53 +57,6 @@ function refreshFailureFromStats(stats, label) {
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
async destroyAll() {
|
||||
this._layerPanel?.destroy();
|
||||
this._layerPanel = null;
|
||||
this._toggleContainer = null;
|
||||
for (const layerId of [...this.layers.keys()]) {
|
||||
await this.destroyLayer(layerId);
|
||||
}
|
||||
@@ -2019,98 +1967,6 @@ export class DataLayerManager {
|
||||
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) {
|
||||
const module = this.layers.get(layerId)?.module;
|
||||
if (typeof module?.getRowControls !== 'function') return null;
|
||||
@@ -2122,186 +1978,25 @@ export class DataLayerManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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._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`;
|
||||
_getLayerPanel() {
|
||||
if (!this._layerPanel) this._layerPanel = new LayerPanel({
|
||||
getLayers: () => this.getAll(),
|
||||
isEnabled: (id) => this.isEnabled(id),
|
||||
setEnabled: (id, enabled, options) => this.setEnabled(id, enabled, options),
|
||||
setLayerParams: (id, params, options) => this.setLayerParams(id, params, options),
|
||||
getRowControls: (id) => this._rowControlsFor(id),
|
||||
hasRowControls: (id) => typeof this.layers.get(id)?.module?.getRowControls === 'function',
|
||||
subscribeRowControls: (id, listener) => {
|
||||
const module = this.layers.get(id)?.module;
|
||||
module?.setRowControlsListener?.(listener);
|
||||
return () => module?.setRowControlsListener?.(null);
|
||||
},
|
||||
onHiddenRefresh: () => { this._panelRefreshPendingOnVisible = true; },
|
||||
});
|
||||
return this._layerPanel;
|
||||
}
|
||||
_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); }
|
||||
}
|
||||
@@ -105,6 +105,7 @@ test('renders ordinary layer rows without recreating a panel-hidden coordinator'
|
||||
},
|
||||
appendChild(child) { this.children.push(child); return child; },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); },
|
||||
querySelector(selector) {
|
||||
if (selector.startsWith('[data-layer-id="')) {
|
||||
@@ -2756,6 +2757,7 @@ function makeControlElement() {
|
||||
},
|
||||
focus() { if (globalThis.document) globalThis.document.activeElement = this; },
|
||||
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); },
|
||||
getAttribute(name) { return this.attributes[name] ?? null; },
|
||||
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.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;
|
||||
}
|
||||
});
|
||||
+188
-86
@@ -16,21 +16,34 @@ function finiteCount(value) {
|
||||
/** Normalize one manager layer into a small loading-feedback record. */
|
||||
export function normalizeLayerLoading(layer = {}) {
|
||||
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 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 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
|
||||
// normal operation, never a batch failure — mirrors layerFeedState's carve-out
|
||||
// so a prompt stored alongside the status cannot turn the chip red.
|
||||
const guidance = GUIDANCE_STATUSES.includes(status);
|
||||
const error = stoppingInstallations ? null
|
||||
: (!guidance && stats.error) || stats.lastError || stats.managerRefreshError || null;
|
||||
const unavailable = !stoppingInstallations && (stats.unavailable === true
|
||||
|| stats.available === false
|
||||
|| ['unavailable', 'offline', 'down', 'error'].includes(status));
|
||||
const error = stoppingInstallations
|
||||
? null
|
||||
: (!guidance && stats.error) ||
|
||||
stats.lastError ||
|
||||
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 degraded = stats.degraded === true || Boolean(error);
|
||||
const accepted = Boolean(stats.lastUpdate) || count > 0;
|
||||
@@ -39,7 +52,8 @@ export function normalizeLayerLoading(layer = {}) {
|
||||
label: String(layer.name || layer.id || 'Layer'),
|
||||
loading,
|
||||
disabling,
|
||||
refresh: loading && layer.enabled && (stats.refreshing === true || accepted),
|
||||
refresh:
|
||||
loading && layer.enabled && (stats.refreshing === true || accepted),
|
||||
lifecycleState,
|
||||
count,
|
||||
accepted,
|
||||
@@ -47,18 +61,27 @@ export function normalizeLayerLoading(layer = {}) {
|
||||
unavailable,
|
||||
keyRequired,
|
||||
degraded,
|
||||
installationRetry: layer.id === 'military-installations' && layer.enabled && !disabling
|
||||
? { retryAt: Number(stats.retryAt) || 0, retrying: stats.retrying === true,
|
||||
failureReason: stats.failureReason, loading, status: stats.status }
|
||||
: null,
|
||||
installationRetry:
|
||||
layer.id === 'military-installations' && layer.enabled && !disabling
|
||||
? {
|
||||
retryAt: Number(stats.retryAt) || 0,
|
||||
retrying: stats.retrying === true,
|
||||
failureReason: stats.failureReason,
|
||||
loading,
|
||||
status: stats.status,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function terminalFromParticipantStats(summary, participantIds) {
|
||||
if (!participantIds?.length) return null;
|
||||
const participants = new Set(participantIds);
|
||||
return summary.records.some((record) => participants.has(record.id)
|
||||
&& (record.error || record.unavailable || record.keyRequired))
|
||||
return summary.records.some(
|
||||
(record) =>
|
||||
participants.has(record.id) &&
|
||||
(record.error || record.unavailable || record.keyRequired),
|
||||
)
|
||||
? 'error'
|
||||
: null;
|
||||
}
|
||||
@@ -67,13 +90,17 @@ function terminalFromParticipantStats(summary, participantIds) {
|
||||
export function aggregateLayerLoading(layers = []) {
|
||||
const records = layers.map(normalizeLayerLoading);
|
||||
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 {
|
||||
records,
|
||||
active,
|
||||
activeIds: active.map((record) => record.id),
|
||||
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. */
|
||||
export function createGlobalStatusNotice(message, nowMs = 0, {
|
||||
state = 'error',
|
||||
detail = '',
|
||||
persistent = false,
|
||||
} = {}) {
|
||||
export function createGlobalStatusNotice(
|
||||
message,
|
||||
nowMs = 0,
|
||||
{ state = 'error', detail = '', persistent = false } = {},
|
||||
) {
|
||||
const label = String(message || '').trim();
|
||||
if (!label) return null;
|
||||
return {
|
||||
@@ -117,9 +144,11 @@ export function presentGlobalStatusNotice(notice, nowMs = 0) {
|
||||
const now = Number.isFinite(nowMs) ? nowMs : 0;
|
||||
if (!notice?.label) return null;
|
||||
if (!notice.persistent && !Number.isFinite(notice.hideAt)) {
|
||||
notice.hideAt = now + (Number.isFinite(notice.dwellMs)
|
||||
? notice.dwellMs
|
||||
: LOADING_FAILURE_DWELL_MS);
|
||||
notice.hideAt =
|
||||
now +
|
||||
(Number.isFinite(notice.dwellMs)
|
||||
? notice.dwellMs
|
||||
: LOADING_FAILURE_DWELL_MS);
|
||||
}
|
||||
if (Number.isFinite(notice.hideAt) && now >= notice.hideAt) return null;
|
||||
return {
|
||||
@@ -130,10 +159,16 @@ export function presentGlobalStatusNotice(notice, nowMs = 0) {
|
||||
}
|
||||
|
||||
/** Whether deferred notice work still owns the current presentation epoch. */
|
||||
export function canPresentDeferredStatusNotice(expectedGeneration, currentGeneration, disposed = false) {
|
||||
return !disposed
|
||||
&& Number.isSafeInteger(expectedGeneration)
|
||||
&& expectedGeneration === currentGeneration;
|
||||
export function canPresentDeferredStatusNotice(
|
||||
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
|
||||
* reports it, so it must remain the highest-priority presentation while live.
|
||||
*/
|
||||
export function presentGlobalLoadingStatus(notice, loadingState, summary, nowMs = 0) {
|
||||
const loadingPresentation = presentLoadingFeedback(loadingState, summary, nowMs);
|
||||
if (['error', 'retry'].includes(loadingPresentation?.state)) return loadingPresentation;
|
||||
export function presentGlobalLoadingStatus(
|
||||
notice,
|
||||
loadingState,
|
||||
summary,
|
||||
nowMs = 0,
|
||||
) {
|
||||
const loadingPresentation = presentLoadingFeedback(
|
||||
loadingState,
|
||||
summary,
|
||||
nowMs,
|
||||
);
|
||||
if (['error', 'retry'].includes(loadingPresentation?.state))
|
||||
return loadingPresentation;
|
||||
return presentGlobalStatusNotice(notice, nowMs) || loadingPresentation;
|
||||
}
|
||||
|
||||
@@ -162,11 +207,11 @@ export function createTrafficSyncFeedbackState() {
|
||||
* Reduce one sampled Street Traffic status without extending completion on
|
||||
* every animation-loop poll. Coverage describes accepted data, not work.
|
||||
*/
|
||||
export function reduceTrafficSyncFeedback(previous, {
|
||||
enabled = false,
|
||||
stats = {},
|
||||
forceShow = false,
|
||||
} = {}, nowMs = 0) {
|
||||
export function reduceTrafficSyncFeedback(
|
||||
previous,
|
||||
{ enabled = false, stats = {}, forceShow = false } = {},
|
||||
nowMs = 0,
|
||||
) {
|
||||
const state = previous || createTrafficSyncFeedbackState();
|
||||
const now = Number.isFinite(nowMs) ? nowMs : 0;
|
||||
if (!enabled) return createTrafficSyncFeedbackState();
|
||||
@@ -174,10 +219,13 @@ export function reduceTrafficSyncFeedback(previous, {
|
||||
const hasProgress = Number.isFinite(stats.phaseProgressPct);
|
||||
const progressPct = hasProgress
|
||||
? Math.max(0, Math.min(100, Math.round(stats.phaseProgressPct)))
|
||||
: (stats.loading ? 1 : 100);
|
||||
const busy = stats.loading === true
|
||||
|| stats.worldJumping === true
|
||||
|| (hasProgress && (progressPct < 100 || (stats.prewarmQueueDepth ?? 0) > 0));
|
||||
: stats.loading
|
||||
? 1
|
||||
: 100;
|
||||
const busy =
|
||||
stats.loading === true ||
|
||||
stats.worldJumping === true ||
|
||||
(hasProgress && (progressPct < 100 || (stats.prewarmQueueDepth ?? 0) > 0));
|
||||
const label = String(stats.phaseLabel || stats.loadingLabel || '').trim();
|
||||
|
||||
if (busy) {
|
||||
@@ -193,13 +241,13 @@ export function reduceTrafficSyncFeedback(previous, {
|
||||
};
|
||||
}
|
||||
|
||||
const existingConfirmation = state.confirmationUntil > now
|
||||
? state.confirmationUntil
|
||||
: 0;
|
||||
const confirmationUntil = existingConfirmation || (state.busy || forceShow
|
||||
? now + TRAFFIC_SYNC_CONFIRM_MS
|
||||
: 0);
|
||||
const visible = confirmationUntil > now && progressPct >= 100 && Boolean(label);
|
||||
const existingConfirmation =
|
||||
state.confirmationUntil > now ? state.confirmationUntil : 0;
|
||||
const confirmationUntil =
|
||||
existingConfirmation ||
|
||||
(state.busy || forceShow ? now + TRAFFIC_SYNC_CONFIRM_MS : 0);
|
||||
const visible =
|
||||
confirmationUntil > now && progressPct >= 100 && Boolean(label);
|
||||
return {
|
||||
busy: false,
|
||||
visible,
|
||||
@@ -217,7 +265,8 @@ export function reduceTrafficSyncFeedback(previous, {
|
||||
|
||||
function terminalFromEvent(event) {
|
||||
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' || type === 'refresh') return 'complete';
|
||||
return null;
|
||||
@@ -238,7 +287,9 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
|
||||
const beginning = state.phase !== 'loading';
|
||||
const startedAt = beginning ? now : state.startedAt;
|
||||
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 eventParticipates = eventLayerId && activeIds.includes(eventLayerId);
|
||||
const batchOutcome = mergeTerminalOutcome(
|
||||
@@ -257,27 +308,41 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
|
||||
activeIds,
|
||||
batchOutcome,
|
||||
terminal: null,
|
||||
operation: summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading',
|
||||
failedEventIds: [...new Set([
|
||||
...(beginning ? [] : state.failedEventIds || []),
|
||||
...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []),
|
||||
])],
|
||||
operation: summary.disabling
|
||||
? 'disabling'
|
||||
: summary.refresh
|
||||
? 'refresh'
|
||||
: 'loading',
|
||||
failedEventIds: [
|
||||
...new Set([
|
||||
...(beginning ? [] : state.failedEventIds || []),
|
||||
...(eventParticipates && terminalFromEvent(event) === 'error'
|
||||
? [eventLayerId]
|
||||
: []),
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (state.phase === 'loading') {
|
||||
const eventLayerId = String(event?.layerId || '');
|
||||
const eventParticipates = eventLayerId && state.activeIds.includes(eventLayerId);
|
||||
const terminal = mergeTerminalOutcome(
|
||||
const eventParticipates =
|
||||
eventLayerId && state.activeIds.includes(eventLayerId);
|
||||
const terminal =
|
||||
mergeTerminalOutcome(
|
||||
state.batchOutcome,
|
||||
terminalFromParticipantStats(summary, state.activeIds),
|
||||
),
|
||||
eventParticipates ? terminalFromEvent(event) : null,
|
||||
) || 'complete';
|
||||
mergeTerminalOutcome(
|
||||
state.batchOutcome,
|
||||
terminalFromParticipantStats(summary, state.activeIds),
|
||||
),
|
||||
eventParticipates ? terminalFromEvent(event) : null,
|
||||
) || 'complete';
|
||||
const wasVisible = state.visible || now >= state.showAt;
|
||||
if (!wasVisible && terminal === 'complete') return createLoadingFeedbackState();
|
||||
const dwell = terminal === 'error' ? LOADING_FAILURE_DWELL_MS : LOADING_TERMINAL_DWELL_MS;
|
||||
if (!wasVisible && terminal === 'complete')
|
||||
return createLoadingFeedbackState();
|
||||
const dwell =
|
||||
terminal === 'error'
|
||||
? LOADING_FAILURE_DWELL_MS
|
||||
: LOADING_TERMINAL_DWELL_MS;
|
||||
return {
|
||||
...state,
|
||||
phase: 'terminal',
|
||||
@@ -285,10 +350,14 @@ export function reduceLoadingFeedback(previous, summary, nowMs, event = null) {
|
||||
hideAt: now + dwell,
|
||||
batchOutcome: terminal,
|
||||
terminal,
|
||||
failedEventIds: [...new Set([
|
||||
...(state.failedEventIds || []),
|
||||
...(eventParticipates && terminalFromEvent(event) === 'error' ? [eventLayerId] : []),
|
||||
])],
|
||||
failedEventIds: [
|
||||
...new Set([
|
||||
...(state.failedEventIds || []),
|
||||
...(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. */
|
||||
export function presentLoadingFeedback(state, summary, nowMs) {
|
||||
const site = summary.records.find(record => record.installationRetry?.retryAt > 0);
|
||||
const otherFailure = 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');
|
||||
const site = summary.records.find(
|
||||
(record) => record.installationRetry?.retryAt > 0,
|
||||
);
|
||||
const otherFailure =
|
||||
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
|
||||
// participant's failure or pretending that a scheduled retry is fetching.
|
||||
if (site && !summary.active.length && !otherFailure) {
|
||||
@@ -312,28 +387,55 @@ export function presentLoadingFeedback(state, summary, nowMs) {
|
||||
}
|
||||
if (!state?.visible) return null;
|
||||
if (state.phase === 'terminal') {
|
||||
const labels = { complete: 'LOAD COMPLETE', cancelled: 'LOAD CANCELLED', error: 'LOAD FAILED' };
|
||||
const label = state.operation === 'disabling' && state.terminal === 'complete'
|
||||
? 'LIVE DATA OFF'
|
||||
: state.terminal === 'complete' && state.activeIds?.length === 1 && state.activeIds[0] === 'military-installations'
|
||||
? 'MAPPED SITES LOADED' : labels[state.terminal] || 'LOAD COMPLETE';
|
||||
const labels = {
|
||||
complete: 'LOAD COMPLETE',
|
||||
cancelled: 'LOAD CANCELLED',
|
||||
error: 'LOAD FAILED',
|
||||
};
|
||||
const label =
|
||||
state.operation === 'disabling' && state.terminal === 'complete'
|
||||
? 'LIVE DATA OFF'
|
||||
: state.terminal === 'complete' &&
|
||||
state.activeIds?.length === 1 &&
|
||||
state.activeIds[0] === 'military-installations'
|
||||
? 'MAPPED SITES LOADED'
|
||||
: labels[state.terminal] || 'LOAD COMPLETE';
|
||||
return { state: state.terminal, label, detail: '' };
|
||||
}
|
||||
const active = summary.active;
|
||||
if (active.length === 1 && active[0].installationRetry && !summary.disabling) {
|
||||
return { state: 'loading', label: active[0].installationRetry.retrying
|
||||
? 'RETRYING MAPPED SITES' : 'FETCHING MAPPED SITES', detail: 'OpenStreetMap · Overpass' };
|
||||
if (
|
||||
active.length === 1 &&
|
||||
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 label = summary.disabling
|
||||
? 'TURNING OFF LIVE DATA'
|
||||
: summary.refresh ? 'REFRESHING LIVE DATA' : 'LOADING LIVE DATA';
|
||||
const names = active.slice(0, 2).map((record) => record.label).join(' · ');
|
||||
: summary.refresh
|
||||
? '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}` : '';
|
||||
return {
|
||||
state: elapsed >= LOADING_LONG_THRESHOLD_MS
|
||||
? 'long'
|
||||
: summary.disabling ? 'disabling' : summary.refresh ? 'refresh' : 'loading',
|
||||
state:
|
||||
elapsed >= LOADING_LONG_THRESHOLD_MS
|
||||
? 'long'
|
||||
: summary.disabling
|
||||
? 'disabling'
|
||||
: summary.refresh
|
||||
? 'refresh'
|
||||
: 'loading',
|
||||
label,
|
||||
detail: `${names}${suffix}`,
|
||||
};
|
||||
|
||||
@@ -692,6 +692,7 @@ test('visual input listeners are revoked before asynchronous UI teardown', () =>
|
||||
assert.match(synchronous, /this\._applicationShortcuts\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._visualEffects\.stop\(\)/);
|
||||
assert.match(synchronous, /this\._mapSourceControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._clearLayersControl\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._displayControls\?\.destroy\(\)/);
|
||||
assert.match(synchronous, /this\._styleParameters\?\.destroy\(\)/);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindClearLayersControl } from './ui/layers.js';
|
||||
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';
|
||||
@@ -2087,7 +2088,6 @@ export class StyleManager {
|
||||
this._globeResetHandler = null;
|
||||
this._clearSelectedLayersPromise = null;
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
this._clearSelectedLayersHandler = null;
|
||||
this._dataManager = null;
|
||||
this._cctvUnsubscribe = null;
|
||||
this._radioUnsubscribe = null;
|
||||
@@ -8900,8 +8900,8 @@ export class StyleManager {
|
||||
/** Wire the top-center action that clears only manager-owned data layers. */
|
||||
_initClearSelectedLayersButton() {
|
||||
if (!this._clearSelectedLayersBtn) return;
|
||||
this._clearSelectedLayersHandler = () => { void this.clearSelectedLayers(); };
|
||||
this._clearSelectedLayersBtn.addEventListener('click', this._clearSelectedLayersHandler);
|
||||
this._clearLayersControl?.destroy();
|
||||
this._clearLayersControl = bindClearLayersControl(this._clearSelectedLayersBtn, () => this.clearSelectedLayers());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8928,9 +8928,7 @@ export class StyleManager {
|
||||
this._preservePanelStateDuringLayerClear = true;
|
||||
this._syncContextModeButtons();
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'true');
|
||||
this._clearSelectedLayersBtn.setAttribute('aria-busy', 'true');
|
||||
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clearing selected data layers');
|
||||
this._clearLayersControl?.setBusy(true);
|
||||
|
||||
const managerOperation = this._dataManager.clearSelectedLayers({
|
||||
origin: 'user',
|
||||
@@ -8962,9 +8960,7 @@ export class StyleManager {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
this._clearSelectedLayersBtn.setAttribute('aria-disabled', 'false');
|
||||
this._clearSelectedLayersBtn.setAttribute('aria-busy', 'false');
|
||||
this._clearSelectedLayersBtn.setAttribute('aria-label', 'Clear selected data layers');
|
||||
this._clearLayersControl?.setBusy(false);
|
||||
this._preservePanelStateDuringLayerClear = false;
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
this._clearSelectedLayersPromise = null;
|
||||
@@ -9377,6 +9373,7 @@ export class StyleManager {
|
||||
this._applicationShortcuts?.destroy();
|
||||
this._displayControls?.destroy();
|
||||
this._mapSourceControls?.destroy();
|
||||
this._clearLayersControl?.destroy();
|
||||
this._visualEffects.stop();
|
||||
this._styleParameters?.destroy();
|
||||
for (const control of this._panelDisclosureControls || []) control.destroy();
|
||||
@@ -9466,10 +9463,7 @@ export class StyleManager {
|
||||
this._cockpitResetGlobeBtn?.removeEventListener('click', this._globeResetHandler);
|
||||
this._globeResetHandler = null;
|
||||
}
|
||||
if (this._clearSelectedLayersBtn && this._clearSelectedLayersHandler) {
|
||||
this._clearSelectedLayersBtn.removeEventListener('click', this._clearSelectedLayersHandler);
|
||||
this._clearSelectedLayersHandler = null;
|
||||
}
|
||||
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._commandDockTrayObserver?.disconnect?.();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LayerPanel, layerFeedState } from './layerPanel.js';
|
||||
export { bindClearLayersControl } from './clearLayersControl.js';
|
||||
Reference in new issue
Block a user