From 07cf1695ff0141a51a376d8d00379a049d157e05 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:36:04 -0700 Subject: [PATCH 1/8] refactor: separate Layers panel presentation from lifecycle actions --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 7 + docs/CURRENT-STATE.md | 9 + package.json | 5 +- scripts/format-scope.json | 5 +- scripts/package-boundaries.json | 216 +++++++++++++----- src/cockpitMarkup.test.mjs | 4 +- src/contextTabKeyboard.test.mjs | 5 +- src/data/manager.js | 355 +++--------------------------- src/data/manager.test.mjs | 35 +++ src/ui.js | 21 +- src/ui/clearLayersControl.js | 18 ++ src/ui/layerPanel.js | 379 ++++++++++++++++++++++++++++++++ src/ui/layers.js | 2 + 14 files changed, 665 insertions(+), 398 deletions(-) create mode 100644 src/ui/clearLayersControl.js create mode 100644 src/ui/layerPanel.js create mode 100644 src/ui/layers.js 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, legend?: Array }|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); } } diff --git a/src/data/manager.test.mjs b/src/data/manager.test.mjs index 73e3d4bad..e2f084d62 100644 --- a/src/data/manager.test.mjs +++ b/src/data/manager.test.mjs @@ -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; + } +}); diff --git a/src/ui.js b/src/ui.js index 2e7926541..74c6c8386 100644 --- a/src/ui.js +++ b/src/ui.js @@ -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; @@ -3331,6 +3331,7 @@ export class StyleManager { _initMapStackControl() { if (!this.mapStackController) return; this._mapSourceControls?.destroy(); + this._clearLayersControl?.destroy(); this._mapSourceControls = createMapSourceControls({ container: this._mapStackChips, statusElement: this._mapStackStatus, @@ -8900,8 +8901,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 +8929,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 +8961,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 +9374,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 +9464,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?.(); diff --git a/src/ui/clearLayersControl.js b/src/ui/clearLayersControl.js new file mode 100644 index 000000000..2036f416e --- /dev/null +++ b/src/ui/clearLayersControl.js @@ -0,0 +1,18 @@ +/** 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); + }, + }; +} diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js new file mode 100644 index 000000000..126b7d80b --- /dev/null +++ b/src/ui/layerPanel.js @@ -0,0 +1,379 @@ +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, legend?: Array }|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`; + } +} diff --git a/src/ui/layers.js b/src/ui/layers.js new file mode 100644 index 000000000..10ad8b530 --- /dev/null +++ b/src/ui/layers.js @@ -0,0 +1,2 @@ +export { LayerPanel, layerFeedState } from './layerPanel.js'; +export { bindClearLayersControl } from './clearLayersControl.js'; From e34d6199494c003751cdd9f204805d654e59d150 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:36:06 -0700 Subject: [PATCH 2/8] style: format Layers panel components --- scripts/package-boundaries.json | 207 ++++++++------------------------ src/ui/clearLayersControl.js | 9 +- src/ui/layerPanel.js | 147 +++++++++++++++-------- 3 files changed, 158 insertions(+), 205 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 433fa873f..c79b5843b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "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", @@ -65,24 +45,16 @@ "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", @@ -97,19 +69,13 @@ "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", @@ -120,19 +86,13 @@ "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" @@ -140,50 +100,30 @@ "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", @@ -192,19 +132,13 @@ "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", @@ -221,9 +155,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -237,9 +169,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -258,9 +188,7 @@ }, "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", @@ -277,9 +205,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -298,9 +224,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -320,9 +244,7 @@ }, "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", @@ -333,9 +255,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -345,27 +265,17 @@ "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", @@ -377,9 +287,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -388,19 +296,12 @@ "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", @@ -413,14 +314,10 @@ "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", @@ -430,9 +327,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers" - ], + "exports": ["./ui/layers"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", diff --git a/src/ui/clearLayersControl.js b/src/ui/clearLayersControl.js index 2036f416e..a3e0e078f 100644 --- a/src/ui/clearLayersControl.js +++ b/src/ui/clearLayersControl.js @@ -1,14 +1,19 @@ /** 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(); }; + 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'); + button.setAttribute( + 'aria-label', + busy ? 'Clearing selected data layers' : 'Clear selected data layers', + ); }, destroy() { destroyed = true; diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 126b7d80b..6bd94bf9f 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -15,16 +15,21 @@ const FEED_STATE_LABELS = Object.freeze({ */ export function layerFeedState(stats = {}) { const state = stats || {}; - const status = typeof state.status === 'string' ? state.status.toLowerCase() : ''; + 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'; + 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) + (presentedError || + state.unavailable === true || + state.available === false) && + !hasPriorData && + !GUIDANCE_STATUSES.includes(status) ) { return 'unavailable'; } @@ -37,29 +42,37 @@ export function layerFeedState(stats = {}) { 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)) + 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'; + 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 = () => {} }) { + constructor({ + getLayers, + isEnabled, + setEnabled, + setLayerParams, + getRowControls, + hasRowControls, + subscribeRowControls, + onHiddenRefresh = () => {}, + }) { this.getAll = getLayers; this.isEnabled = isEnabled; this.setEnabled = setEnabled; @@ -123,7 +136,9 @@ export class LayerPanel { const count = document.createElement('span'); count.className = 'data-count'; - count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—'; + count.textContent = layer.stats.count + ? this._formatCount(layer.stats.count) + : '—'; const toggle = document.createElement('button'); toggle.type = 'button'; @@ -133,16 +148,24 @@ export class LayerPanel { // 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; + 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' }); + 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); + if (!this._destroyed && current && this._generation === generation) + this._syncToggleButton(toggle, current); } }); @@ -165,7 +188,9 @@ export class LayerPanel { // 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()); + const unsubscribe = this.subscribeRowControls(layer.id, () => + this._refreshTogglePanel(), + ); if (unsubscribe) this._removers.push(unsubscribe); const controls = document.createElement('div'); controls.className = 'data-toggle-controls'; @@ -174,9 +199,11 @@ export class LayerPanel { 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' }); + 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); @@ -215,7 +242,10 @@ export class LayerPanel { 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(); + if ( + String(node.className).split(/\s+/).includes('data-toggle-legend-item') + ) + node.remove(); } const stale = new Map(); @@ -265,7 +295,9 @@ export class LayerPanel { return; } for (const layer of this.getAll()) { - const row = this._toggleContainer.querySelector(`[data-layer-id="${layer.id}"]`); + const row = this._toggleContainer.querySelector( + `[data-layer-id="${layer.id}"]`, + ); if (!row) continue; const btn = row.querySelector('.data-toggle-btn'); @@ -275,7 +307,9 @@ export class LayerPanel { const count = row.querySelector('.data-count'); if (count) { - count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—'; + count.textContent = layer.stats.count + ? this._formatCount(layer.stats.count) + : '—'; } const meta = row.querySelector('.data-toggle-meta'); @@ -292,14 +326,16 @@ export class LayerPanel { const feedState = layerFeedState(stats); const stateLabel = FEED_STATE_LABELS[feedState]; const source = stats.source || layer.source; - const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'); + 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; + 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`; @@ -308,27 +344,33 @@ export class LayerPanel { } // 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()) { + 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...'; + 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); + 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` - : ''; + 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()) { @@ -339,7 +381,9 @@ export class LayerPanel { _syncToggleButton(button, layer) { const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off'; - const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling'; + const transitioning = + layer.lifecycleState === 'enabling' || + layer.lifecycleState === 'disabling'; const uncertain = Boolean(layer.lifecycleUncertain); button.classList.toggle('active', layer.enabled); button.classList.toggle('transitioning', transitioning); @@ -347,11 +391,16 @@ export class LayerPanel { 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.classList.toggle( + `feed-${state}`, + layer.enabled && !uncertain && feedState === state, + ); } button.dataset.feedState = transitioning ? layer.lifecycleState - : (uncertain ? 'uncertain' : feedState); + : 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`. @@ -360,7 +409,11 @@ export class LayerPanel { button.setAttribute('aria-busy', String(transitioning)); button.textContent = transitioning ? layer.lifecycleState.toUpperCase() - : (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF')); + : uncertain + ? 'UNCERTAIN' + : layer.enabled + ? FEED_STATE_LABELS[feedState] + : 'OFF'; button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`); } From 4a3f53bfcd9a084291f77f6c8fd2ef74c872c885 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:36:48 -0700 Subject: [PATCH 3/8] test: verify Layers panel replacement in the browser --- scripts/format-scope.json | 3 +- scripts/qa-layer-panel.mjs | 115 +++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-layer-panel.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ea5240d82..16dbe251a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -156,5 +156,6 @@ "scripts/qa-map-source-controls.mjs", "src/ui/layers.js", "src/ui/layerPanel.js", - "src/ui/clearLayersControl.js" + "src/ui/clearLayersControl.js", + "scripts/qa-layer-panel.mjs" ] diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs new file mode 100644 index 000000000..ef458bee7 --- /dev/null +++ b/scripts/qa-layer-panel.mjs @@ -0,0 +1,115 @@ +#!/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([ + 'count is presented from the supplied snapshot', + row().querySelector('.data-count').textContent === '1.3K', + ]); + 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([ + 'feed state reflects the settled layer snapshot', + row().querySelector('.data-toggle-btn').dataset.feedState === 'stale', + ]); + 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; From 648041e0400525e960cdf3e1c4e5ce13afd403e7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:37:32 -0700 Subject: [PATCH 4/8] fix: keep clear control alive when map controls are initialized --- src/sharelink.celestial.test.mjs | 1 + src/ui.js | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index dbfbf5fe2..5c1df1fc9 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -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\(\)/); }); diff --git a/src/ui.js b/src/ui.js index 74c6c8386..9cee03d13 100644 --- a/src/ui.js +++ b/src/ui.js @@ -3331,7 +3331,6 @@ export class StyleManager { _initMapStackControl() { if (!this.mapStackController) return; this._mapSourceControls?.destroy(); - this._clearLayersControl?.destroy(); this._mapSourceControls = createMapSourceControls({ container: this._mapStackChips, statusElement: this._mapStackStatus, From 4d764a82db95c780430749ed24c73cf70ebc5338 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:38:04 -0700 Subject: [PATCH 5/8] test: exercise native clear action through layer transaction --- scripts/qa-layer-panel.mjs | 14 ++++++++++++++ src/data/manager.js | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs index ef458bee7..e5885fafa 100644 --- a/scripts/qa-layer-panel.mjs +++ b/scripts/qa-layer-panel.mjs @@ -82,6 +82,20 @@ try { '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(); diff --git a/src/data/manager.js b/src/data/manager.js index 1d1b5c35e..682f6115b 100644 --- a/src/data/manager.js +++ b/src/data/manager.js @@ -1,5 +1,5 @@ import { governorRequestRender } from '../renderGovernor.js'; -import { LayerPanel, layerFeedState } from '../ui/layers.js'; +import { LayerPanel } from '../ui/layers.js'; export { layerFeedState } from '../ui/layers.js'; import { markDetectionSourcesChanged } from './detection.js'; function cloneLayerParams(value) { From a41f3ec3cdb0e0dfef02a48f5b2668425c2e02c9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:41:07 -0700 Subject: [PATCH 6/8] refactor: expose pure layer loading feedback helpers --- docs/CODE-BOUNDARIES.md | 3 + package.json | 5 +- scripts/format-scope.json | 4 +- scripts/package-boundaries.json | 208 ++++++++++++++++++++++++-------- 4 files changed, 166 insertions(+), 54 deletions(-) diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 86c4ceac1..9eec38dc5 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -276,3 +276,6 @@ the supplied controller. 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/package.json b/package.json index aeae47f2f..e78bcbf8b 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 — 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 \u2014 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", @@ -127,6 +127,7 @@ "./ui/effects": "./src/ui/effects.js", "./ui/effects/bloom": "./src/bloom.js", "./ui/maps": "./src/ui/mapSource.js", - "./ui/layers": "./src/ui/layers.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 16dbe251a..2dfafec41 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -157,5 +157,7 @@ "src/ui/layers.js", "src/ui/layerPanel.js", "src/ui/clearLayersControl.js", - "scripts/qa-layer-panel.mjs" + "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 c79b5843b..73807b350 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", @@ -327,7 +430,10 @@ "external": [] }, "layer-panel": { - "exports": ["./ui/layers"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", From dec9dd288c24695dfd3ee36658d9d10b5666cd3d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:41:08 -0700 Subject: [PATCH 7/8] style: format layer loading feedback helpers --- scripts/package-boundaries.json | 208 ++++++----------------- src/data/installationFeedback.js | 9 +- src/loadingFeedback.js | 274 +++++++++++++++++++++---------- 3 files changed, 245 insertions(+), 246 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 73807b350..971bed61b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "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", @@ -65,24 +45,16 @@ "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", @@ -97,19 +69,13 @@ "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", @@ -120,19 +86,13 @@ "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" @@ -140,50 +100,30 @@ "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", @@ -192,19 +132,13 @@ "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", @@ -221,9 +155,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -237,9 +169,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -258,9 +188,7 @@ }, "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", @@ -277,9 +205,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -298,9 +224,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -320,9 +244,7 @@ }, "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", @@ -333,9 +255,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -345,27 +265,17 @@ "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", @@ -377,9 +287,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -388,19 +296,12 @@ "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", @@ -413,14 +314,10 @@ "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", @@ -430,10 +327,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", 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/loadingFeedback.js b/src/loadingFeedback.js index a5540e1ce..85eb38606 100644 --- a/src/loadingFeedback.js +++ b/src/loadingFeedback.js @@ -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}`, }; From 9f6cde79fdbd96be277688983720f8d7717a9c8d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 12 Sep 2026 22:43:01 -0700 Subject: [PATCH 8/8] test: check layer counts after lifecycle initialization --- scripts/qa-layer-panel.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/qa-layer-panel.mjs b/scripts/qa-layer-panel.mjs index e5885fafa..189de40bc 100644 --- a/scripts/qa-layer-panel.mjs +++ b/scripts/qa-layer-panel.mjs @@ -62,10 +62,6 @@ try { row().querySelector('.data-name').textContent === 'Literal layer' && !row().querySelector('b'), ]); - result.push([ - 'count is presented from the supplied snapshot', - row().querySelector('.data-count').textContent === '1.3K', - ]); result.push([ 'row subscription is installed', typeof listener === 'function', @@ -78,6 +74,10 @@ try { 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',