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..86c4ceac1 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -269,3 +269,10 @@ 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.
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..aeae47f2f 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "gods-eye-view",
"private": true,
"version": "0.1.1",
- "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
+ "description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"type": "module",
"license": "MIT",
"author": "Bilawal Sidhu",
@@ -126,6 +126,7 @@
"./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"
}
}
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 44f6e3ab0..ea5240d82 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -153,5 +153,8 @@
"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"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 97c6601c8..433fa873f 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -10,27 +10,47 @@
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js"
],
- "external": ["cesium"]
+ "external": [
+ "cesium"
+ ]
},
"application": {
- "exports": ["./application"],
- "modules": ["src/app/application.js"],
+ "exports": [
+ "./application"
+ ],
+ "modules": [
+ "src/app/application.js"
+ ],
"external": []
},
"viewer": {
- "exports": ["./application/viewer"],
- "modules": ["src/app/viewer.js"],
- "external": ["cesium"]
+ "exports": [
+ "./application/viewer"
+ ],
+ "modules": [
+ "src/app/viewer.js"
+ ],
+ "external": [
+ "cesium"
+ ]
},
"vite-build": {
"runtime": "node",
- "exports": ["./build/vite"],
- "modules": ["build/vite.js"],
- "external": ["vite-plugin-cesium"]
+ "exports": [
+ "./build/vite"
+ ],
+ "modules": [
+ "build/vite.js"
+ ],
+ "external": [
+ "vite-plugin-cesium"
+ ]
},
"live-providers": {
"runtime": "node",
- "exports": ["./server/providers/live"],
+ "exports": [
+ "./server/providers/live"
+ ],
"modules": [
"server/providers/live.js",
"server/providers/common/http.js",
@@ -45,16 +65,24 @@
"src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js"
],
- "external": ["ws"]
+ "external": [
+ "ws"
+ ]
},
"adsb-lol-source": {
- "exports": ["./sources/adsb-lol"],
- "modules": ["src/data/adsbLolFallback.js"],
+ "exports": [
+ "./sources/adsb-lol"
+ ],
+ "modules": [
+ "src/data/adsbLolFallback.js"
+ ],
"external": []
},
"place-providers": {
"runtime": "node",
- "exports": ["./server/providers/places"],
+ "exports": [
+ "./server/providers/places"
+ ],
"modules": [
"server/providers/places.js",
"server/providers/places/google.js",
@@ -69,13 +97,19 @@
"external": []
},
"place-payloads": {
- "exports": ["./sources/places"],
- "modules": ["src/data/placeProviderPayloads.js"],
+ "exports": [
+ "./sources/places"
+ ],
+ "modules": [
+ "src/data/placeProviderPayloads.js"
+ ],
"external": []
},
"space-providers": {
"runtime": "node",
- "exports": ["./server/providers/space"],
+ "exports": [
+ "./server/providers/space"
+ ],
"modules": [
"server/providers/space.js",
"server/providers/space/celestrak.js",
@@ -86,13 +120,19 @@
"external": []
},
"space-requests": {
- "exports": ["./sources/space"],
- "modules": ["src/data/spaceProviderRequests.js"],
+ "exports": [
+ "./sources/space"
+ ],
+ "modules": [
+ "src/data/spaceProviderRequests.js"
+ ],
"external": []
},
"terrain-provider": {
"runtime": "node",
- "exports": ["./server/providers/terrain"],
+ "exports": [
+ "./server/providers/terrain"
+ ],
"modules": [
"server/providers/terrain.js",
"src/data/terrainHeightsProxy.js"
@@ -100,30 +140,50 @@
"external": []
},
"terrain-source": {
- "exports": ["./sources/terrain"],
- "modules": ["src/data/terrainHeightsProxy.js"],
+ "exports": [
+ "./sources/terrain"
+ ],
+ "modules": [
+ "src/data/terrainHeightsProxy.js"
+ ],
"external": []
},
"traffic-provider": {
"runtime": "node",
- "exports": ["./server/providers/traffic"],
- "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
+ "exports": [
+ "./server/providers/traffic"
+ ],
+ "modules": [
+ "server/providers/traffic.js",
+ "src/data/tomtomTiles.js"
+ ],
"external": []
},
"traffic-source": {
- "exports": ["./sources/traffic"],
- "modules": ["src/data/tomtomTiles.js"],
+ "exports": [
+ "./sources/traffic"
+ ],
+ "modules": [
+ "src/data/tomtomTiles.js"
+ ],
"external": []
},
"firms-provider": {
"runtime": "node",
- "exports": ["./server/providers/firms"],
- "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
+ "exports": [
+ "./server/providers/firms"
+ ],
+ "modules": [
+ "server/providers/firms.js",
+ "src/data/firmsCsv.js"
+ ],
"external": []
},
"gbfs-provider": {
"runtime": "node",
- "exports": ["./server/providers/gbfs"],
+ "exports": [
+ "./server/providers/gbfs"
+ ],
"modules": [
"server/providers/gbfs.js",
"server/providers/common/http.js",
@@ -132,13 +192,19 @@
"external": []
},
"gbfs-source": {
- "exports": ["./sources/gbfs"],
- "modules": ["src/data/gbfsSource.js"],
+ "exports": [
+ "./sources/gbfs"
+ ],
+ "modules": [
+ "src/data/gbfsSource.js"
+ ],
"external": []
},
"cctv-provider": {
"runtime": "node",
- "exports": ["./server/providers/cctv"],
+ "exports": [
+ "./server/providers/cctv"
+ ],
"modules": [
"server/providers/cctv.js",
"server/providers/cctv/catalog.js",
@@ -155,7 +221,9 @@
},
"radio-provider": {
"runtime": "node",
- "exports": ["./server/providers/radio"],
+ "exports": [
+ "./server/providers/radio"
+ ],
"modules": [
"server/providers/radio.js",
"server/providers/radio/catalog.js",
@@ -169,7 +237,9 @@
},
"overpass-provider": {
"runtime": "node",
- "exports": ["./server/providers/overpass"],
+ "exports": [
+ "./server/providers/overpass"
+ ],
"modules": [
"server/providers/common/geo.js",
"server/providers/common/http.js",
@@ -188,7 +258,9 @@
},
"military-installations-provider": {
"runtime": "node",
- "exports": ["./server/providers/military-installations"],
+ "exports": [
+ "./server/providers/military-installations"
+ ],
"modules": [
"server/providers/common/http.js",
"server/providers/common/query.js",
@@ -205,7 +277,9 @@
},
"regional-provider": {
"runtime": "node",
- "exports": ["./server/providers/regional"],
+ "exports": [
+ "./server/providers/regional"
+ ],
"modules": [
"server/providers/common/http.js",
"server/providers/common/query.js",
@@ -224,7 +298,9 @@
},
"openai-provider": {
"runtime": "node",
- "exports": ["./server/providers/openai"],
+ "exports": [
+ "./server/providers/openai"
+ ],
"modules": [
"server/providers/common/rate-limit.js",
"server/providers/common/request.js",
@@ -244,7 +320,9 @@
},
"standalone-key-setup": {
"runtime": "node",
- "exports": ["./server/standalone/key-setup"],
+ "exports": [
+ "./server/standalone/key-setup"
+ ],
"modules": [
"scripts/pinokio-environment.mjs",
"server/providers/common/source-root.js",
@@ -255,7 +333,9 @@
"external": []
},
"place-search": {
- "exports": ["./search"],
+ "exports": [
+ "./search"
+ ],
"modules": [
"src/search/index.js",
"src/search/placeSearch.js",
@@ -265,17 +345,27 @@
"external": []
},
"panel-disclosure": {
- "exports": ["./ui/panels"],
- "modules": ["src/ui/panelDisclosure.js"],
+ "exports": [
+ "./ui/panels"
+ ],
+ "modules": [
+ "src/ui/panelDisclosure.js"
+ ],
"external": []
},
"surface-keyboard": {
- "exports": ["./ui/surfaces"],
- "modules": ["src/ui/surfaceKeyboard.js"],
+ "exports": [
+ "./ui/surfaces"
+ ],
+ "modules": [
+ "src/ui/surfaceKeyboard.js"
+ ],
"external": []
},
"panel-rail-layout": {
- "exports": ["./ui/layout"],
+ "exports": [
+ "./ui/layout"
+ ],
"modules": [
"src/ui/panelRails.js",
"src/ui/leftPanelRail.js",
@@ -287,7 +377,9 @@
"external": []
},
"visual-input": {
- "exports": ["./ui/input"],
+ "exports": [
+ "./ui/input"
+ ],
"modules": [
"src/ui/visualInput.js",
"src/ui/applicationShortcuts.js",
@@ -296,12 +388,19 @@
"external": []
},
"display-controls": {
- "exports": ["./ui/display"],
- "modules": ["src/ui/displayControls.js"],
+ "exports": [
+ "./ui/display"
+ ],
+ "modules": [
+ "src/ui/displayControls.js"
+ ],
"external": []
},
"visual-effects": {
- "exports": ["./ui/effects", "./ui/effects/bloom"],
+ "exports": [
+ "./ui/effects",
+ "./ui/effects/bloom"
+ ],
"modules": [
"src/ui/effects.js",
"src/ui/visualEffects.js",
@@ -314,10 +413,14 @@
"src/styles/noir.js",
"src/styles/snow.js"
],
- "external": ["cesium"]
+ "external": [
+ "cesium"
+ ]
},
"map-source-controls": {
- "exports": ["./ui/maps"],
+ "exports": [
+ "./ui/maps"
+ ],
"modules": [
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
@@ -325,5 +428,18 @@
"src/keySetupCore.mjs"
],
"external": []
+ },
+ "layer-panel": {
+ "exports": [
+ "./ui/layers"
+ ],
+ "modules": [
+ "src/ui/layers.js",
+ "src/ui/layerPanel.js",
+ "src/ui/clearLayersControl.js",
+ "src/loadingFeedback.js",
+ "src/data/installationFeedback.js"
+ ],
+ "external": []
}
}
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/manager.js b/src/data/manager.js
index 9ba4ae24c..1d1b5c35e 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, layerFeedState } 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