refactor: separate Layers panel presentation from lifecycle actions

This commit is contained in:
Sameh Khamis committed 2026-09-12 22:36:04 -07:00
1 parent 31f1923633
commit 07cf1695ff
14 files changed
+665 -398

No files matched your search

+2
View File
@@ -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.
+7
View File
@@ -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.
+9
View File
@@ -1,5 +1,14 @@
# God's Eye View Current State
## Layer panel ownership
Layer rows, feed feedback, counts, focus-preserving chips and toggle listeners
are owned by a renderer-free panel component. The layer manager supplies current
snapshots, lifecycle actions and row descriptors. Remount and teardown remove
listeners and row subscriptions; obsolete completions do not repaint old rows.
The clear control presents busy state while its existing action owns the transaction.
## Map Source control ownership
Map Source controls own chip listeners, source-state subscriptions and selection
+3 -2
View File
@@ -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"
}
}
+4 -1
View File
@@ -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"
]
+166 -50
View File
@@ -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": []
}
}
+3 -1
View File
@@ -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"/);
+4 -1
View File
@@ -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*=/);
});
+25 -330
View File
@@ -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 = `<span class="data-icon">${layer.icon}</span><span class="data-name">${layer.name}</span>`;
const right = document.createElement('div');
right.className = 'data-toggle-right';
const count = document.createElement('span');
count.className = 'data-count';
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
this._syncToggleButton(toggle, layer);
toggle.addEventListener('click', async () => {
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
// the lifecycle control focusable while it is busy, and enforce the
// same single-flight interaction contract through ARIA instead.
if (toggle.getAttribute('aria-disabled') === 'true') return;
toggle.setAttribute('aria-disabled', 'true');
toggle.setAttribute('aria-busy', 'true');
try {
await this.setEnabled(layer.id, !this.isEnabled(layer.id), { origin: 'user' });
} catch (error) {
console.warn(`[Data] ${layer.id} toggle error:`, error);
} finally {
const current = this.getAll().find(({ id }) => id === layer.id);
if (current) this._syncToggleButton(toggle, current);
}
});
right.appendChild(count);
right.appendChild(toggle);
topRow.appendChild(left);
topRow.appendChild(right);
const bottomRow = document.createElement('div');
bottomRow.className = 'data-toggle-meta';
bottomRow.textContent = this._buildMetaText(layer);
row.appendChild(topRow);
row.appendChild(bottomRow);
// Optional per-layer sub-controls (chips + color legend). The click
// listener is delegated and attached once here, so it survives
// _refreshTogglePanel — which only rewrites the container's contents.
const rowModule = this.layers.get(layer.id)?.module;
if (typeof rowModule?.getRowControls === 'function') {
// A layer whose controls settle asynchronously (a chunked catalog load
// that can also fail) pushes a re-render through this; nothing else
// would repaint the row before its next scheduled refresh.
rowModule.setRowControlsListener?.(() => this._refreshTogglePanel());
const controls = document.createElement('div');
controls.className = 'data-toggle-controls';
controls.addEventListener('click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Re-read the live descriptor rather than trusting the rendered
// chip, so a stale row can never apply an inverted toggle.
const chip = this._rowControlsFor(layer.id)?.chips
?.find((entry) => entry.id === button.dataset.chipId);
if (chip?.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
this._syncRowControls(controls, layer);
}
this._toggleContainer.appendChild(row);
}
}
/**
* Read a layer's optional row-control descriptor, tolerating a throw so one
* misbehaving layer cannot blank the whole panel. Resolved from the registry
* rather than the `getAll()` projection, which deliberately omits `module`.
* @param {string} layerId Registered layer id.
* @returns {{ chips?: Array<object>, legend?: Array<object> }|null} Descriptor.
*/
_rowControlsFor(layerId) {
const module = this.layers.get(layerId)?.module;
if (typeof module?.getRowControls !== 'function') return null;
@@ -2122,186 +1978,25 @@ export class DataLayerManager {
}
}
/**
* Render a layer's row chips and color legend, and keep the whole block
* hidden while the layer is off (or while a dependency owner has surrendered
* it) so a quiet row stays quiet.
*
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
* rebuilt: this runs on every panel refresh — including the one the chip's
* own click triggers — and replacing the node would drop keyboard focus
* mid-interaction. Legend entries hold no focus and no listeners, so they
* are replaced freely.
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
* @param {object} layer Registered layer entry.
*/
_syncRowControls(container, layer) {
if (!container) return;
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
const chips = controls?.chips || [];
const legend = controls?.legend || [];
container.hidden = chips.length === 0 && legend.length === 0;
for (const node of [...container.children]) {
if (String(node.className).split(/\s+/).includes('data-toggle-legend-item')) node.remove();
}
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
}
for (const chip of chips) {
let button = stale.get(chip.id);
stale.delete(chip.id);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.dataset.chipId = chip.id;
container.appendChild(button);
}
const state = chip.state || (chip.active ? 'active' : 'idle');
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
if (button.textContent !== chip.label) button.textContent = chip.label;
button.title = chip.title || '';
button.disabled = Boolean(chip.disabled);
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
}
for (const node of stale.values()) node.remove();
for (const item of legend) {
const entry = document.createElement('span');
entry.className = 'data-toggle-legend-item';
if (item.blurb) entry.title = item.blurb;
const swatch = document.createElement('span');
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
const text = document.createElement('span');
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text);
container.appendChild(entry);
}
}
_refreshTogglePanel() {
if (!this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
// refresh on return. (perf wave 2)
if (typeof document !== 'undefined' && document.hidden) {
this._panelRefreshPendingOnVisible = true;
return;
}
for (const layer of this.getAll()) {
const row = this._toggleContainer.querySelector(`[data-layer-id="${layer.id}"]`);
if (!row) continue;
const btn = row.querySelector('.data-toggle-btn');
if (btn) {
this._syncToggleButton(btn, layer);
}
const count = row.querySelector('.data-count');
if (count) {
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
}
const meta = row.querySelector('.data-toggle-meta');
if (meta) {
meta.textContent = this._buildMetaText(layer);
}
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
}
}
_buildMetaText(layer) {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = FEED_STATE_LABELS[feedState];
const source = stats.source || layer.source;
const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
return `${lifecycleState.toUpperCase()} · ${source}`;
}
if (layer.lifecycleUncertain) {
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
}
const presentedError = stats.error || stats.lastError || stats.managerRefreshError;
if (presentedError) {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`;
}
return `${stateLabel} · ${source} · ${presentedError}`;
}
// A guidance status carries its prompt in `statusMessage`, not `error`, so
// the row still tells the operator what to do without reporting a fault.
if (GUIDANCE_STATUSES.includes(String(stats.status || '').toLowerCase())
&& typeof stats.statusMessage === 'string' && stats.statusMessage.trim()) {
return `${source} · ${stats.statusMessage.trim()}`;
}
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
if (stats.loading) {
const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: 'loading...';
return `${source} · ${loadingLabel}`;
}
if (feedState === 'fallback') {
const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: (stats.coverage || ago);
return `${stateLabel} · ${source} · ${detail}`;
}
if (feedState === 'stale') {
const retry = typeof stats.retryInSec === 'number' && stats.retryInSec > 0
? ` · retrying in ${stats.retryInSec}s`
: '';
return `${stateLabel} · ${source} · ${ago}${retry}`;
}
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return `${source} · ${stats.loadingLabel.trim()}`;
}
return `${source} · ${ago}`;
}
_syncToggleButton(button, layer) {
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling';
const uncertain = Boolean(layer.lifecycleUncertain);
button.classList.toggle('active', layer.enabled);
button.classList.toggle('transitioning', transitioning);
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
button.classList.toggle('lifecycle-uncertain', uncertain);
for (const state of Object.keys(FEED_STATE_LABELS)) {
button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state);
}
button.dataset.feedState = transitioning
? layer.lifecycleState
: (uncertain ? 'uncertain' : feedState);
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
}
_formatCount(n) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}
_timeAgo(timestamp) {
const diff = Math.floor((Date.now() - timestamp) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
_getLayerPanel() {
if (!this._layerPanel) this._layerPanel = new LayerPanel({
getLayers: () => this.getAll(),
isEnabled: (id) => this.isEnabled(id),
setEnabled: (id, enabled, options) => this.setEnabled(id, enabled, options),
setLayerParams: (id, params, options) => this.setLayerParams(id, params, options),
getRowControls: (id) => this._rowControlsFor(id),
hasRowControls: (id) => typeof this.layers.get(id)?.module?.getRowControls === 'function',
subscribeRowControls: (id, listener) => {
const module = this.layers.get(id)?.module;
module?.setRowControlsListener?.(listener);
return () => module?.setRowControlsListener?.(null);
},
onHiddenRefresh: () => { this._panelRefreshPendingOnVisible = true; },
});
return this._layerPanel;
}
_renderToggles() { this._getLayerPanel().mount(this._toggleContainer); }
_refreshTogglePanel() { this._layerPanel?._refreshTogglePanel(); }
_buildMetaText(layer) { return this._getLayerPanel()._buildMetaText(layer); }
_syncToggleButton(button, layer) { this._getLayerPanel()._syncToggleButton(button, layer); }
}
+35
View File
@@ -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;
}
});
+8 -13
View File
@@ -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?.();
+18
View File
@@ -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);
},
};
}
+379
View File
@@ -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<object>, legend?: Array<object> }|null} Descriptor.
*/
/**
* Render a layer's row chips and color legend, and keep the whole block
* hidden while the layer is off (or while a dependency owner has surrendered
* it) so a quiet row stays quiet.
*
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
* rebuilt: this runs on every panel refresh — including the one the chip's
* own click triggers — and replacing the node would drop keyboard focus
* mid-interaction. Legend entries hold no focus and no listeners, so they
* are replaced freely.
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
* @param {object} layer Registered layer entry.
*/
_syncRowControls(container, layer) {
if (!container) return;
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
const chips = controls?.chips || [];
const legend = controls?.legend || [];
container.hidden = chips.length === 0 && legend.length === 0;
for (const node of [...container.children]) {
if (String(node.className).split(/\s+/).includes('data-toggle-legend-item')) node.remove();
}
const stale = new Map();
for (const node of [...container.children]) {
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
}
for (const chip of chips) {
let button = stale.get(chip.id);
stale.delete(chip.id);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.dataset.chipId = chip.id;
container.appendChild(button);
}
const state = chip.state || (chip.active ? 'active' : 'idle');
button.className = `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`;
if (button.textContent !== chip.label) button.textContent = chip.label;
button.title = chip.title || '';
button.disabled = Boolean(chip.disabled);
button.setAttribute('aria-pressed', chip.active ? 'true' : 'false');
button.setAttribute('aria-busy', chip.busy ? 'true' : 'false');
}
for (const node of stale.values()) node.remove();
for (const item of legend) {
const entry = document.createElement('span');
entry.className = 'data-toggle-legend-item';
if (item.blurb) entry.title = item.blurb;
const swatch = document.createElement('span');
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
const text = document.createElement('span');
text.textContent = `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text);
container.appendChild(entry);
}
}
_refreshTogglePanel() {
if (this._destroyed || !this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
// refresh on return. (perf wave 2)
if (typeof document !== 'undefined' && document.hidden) {
this.onHiddenRefresh();
return;
}
for (const layer of this.getAll()) {
const row = this._toggleContainer.querySelector(`[data-layer-id="${layer.id}"]`);
if (!row) continue;
const btn = row.querySelector('.data-toggle-btn');
if (btn) {
this._syncToggleButton(btn, layer);
}
const count = row.querySelector('.data-count');
if (count) {
count.textContent = layer.stats.count ? this._formatCount(layer.stats.count) : '—';
}
const meta = row.querySelector('.data-toggle-meta');
if (meta) {
meta.textContent = this._buildMetaText(layer);
}
this._syncRowControls(row.querySelector('.data-toggle-controls'), layer);
}
}
_buildMetaText(layer) {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = FEED_STATE_LABELS[feedState];
const source = stats.source || layer.source;
const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
return `${lifecycleState.toUpperCase()} · ${source}`;
}
if (layer.lifecycleUncertain) {
return `UNCERTAIN · ${source} · lifecycle state requires reconciliation`;
}
const presentedError = stats.error || stats.lastError || stats.managerRefreshError;
if (presentedError) {
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
return `${stateLabel} · ${source} · ${presentedError} · retry ${stats.retryInSec}s`;
}
return `${stateLabel} · ${source} · ${presentedError}`;
}
// A guidance status carries its prompt in `statusMessage`, not `error`, so
// the row still tells the operator what to do without reporting a fault.
if (GUIDANCE_STATUSES.includes(String(stats.status || '').toLowerCase())
&& typeof stats.statusMessage === 'string' && stats.statusMessage.trim()) {
return `${source} · ${stats.statusMessage.trim()}`;
}
const ago = stats.lastUpdate ? this._timeAgo(stats.lastUpdate) : 'never';
if (stats.loading) {
const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: 'loading...';
return `${source} · ${loadingLabel}`;
}
if (feedState === 'fallback') {
const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? stats.loadingLabel.trim()
: (stats.coverage || ago);
return `${stateLabel} · ${source} · ${detail}`;
}
if (feedState === 'stale') {
const retry = typeof stats.retryInSec === 'number' && stats.retryInSec > 0
? ` · retrying in ${stats.retryInSec}s`
: '';
return `${stateLabel} · ${source} · ${ago}${retry}`;
}
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return `${source} · ${stats.loadingLabel.trim()}`;
}
return `${source} · ${ago}`;
}
_syncToggleButton(button, layer) {
const feedState = layer.enabled ? layerFeedState(layer.stats) : 'off';
const transitioning = layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling';
const uncertain = Boolean(layer.lifecycleUncertain);
button.classList.toggle('active', layer.enabled);
button.classList.toggle('transitioning', transitioning);
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
button.classList.toggle('lifecycle-uncertain', uncertain);
for (const state of Object.keys(FEED_STATE_LABELS)) {
button.classList.toggle(`feed-${state}`, layer.enabled && !uncertain && feedState === state);
}
button.dataset.feedState = transitioning
? layer.lifecycleState
: (uncertain ? 'uncertain' : feedState);
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute('aria-disabled', String(transitioning));
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
? layer.lifecycleState.toUpperCase()
: (uncertain ? 'UNCERTAIN' : (layer.enabled ? FEED_STATE_LABELS[feedState] : 'OFF'));
button.setAttribute('aria-label', `${layer.name}: ${button.textContent}`);
}
_formatCount(n) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n);
}
_timeAgo(timestamp) {
const diff = Math.floor((Date.now() - timestamp) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
}
+2
View File
@@ -0,0 +1,2 @@
export { LayerPanel, layerFeedState } from './layerPanel.js';
export { bindClearLayersControl } from './clearLayersControl.js';