diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14f7fd9d5..5609b843d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
+- 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.
diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index 5ade016ed..9eec38dc5 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -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.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index f01f25a4f..7c5b2ec96 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -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
diff --git a/package.json b/package.json
index 3d2a7bbad..e78bcbf8b 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 44f6e3ab0..2dfafec41 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -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"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 97c6601c8..971bed61b 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -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": []
}
}
diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs
new file mode 100644
index 000000000..189de40bc
--- /dev/null
+++ b/scripts/qa-layer-panel.mjs
@@ -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: 'Literal layer',
+ 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 ===
+ 'Literal layer' && !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;
diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs
index 2090667c8..7edd4aea2 100644
--- a/src/cockpitMarkup.test.mjs
+++ b/src/cockpitMarkup.test.mjs
@@ -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"/);
diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs
index 7547e0f52..1e58efa06 100644
--- a/src/contextTabKeyboard.test.mjs
+++ b/src/contextTabKeyboard.test.mjs
@@ -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*=/);
});
diff --git a/src/data/installationFeedback.js b/src/data/installationFeedback.js
index 974568a45..f33ce59ab 100644
--- a/src/data/installationFeedback.js
+++ b/src/data/installationFeedback.js
@@ -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';
diff --git a/src/data/manager.js b/src/data/manager.js
index 9ba4ae24c..682f6115b 100644
--- a/src/data/manager.js
+++ b/src/data/manager.js
@@ -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 = `${layer.icon}${layer.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);
- 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