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';