import { syncChipGroup } from './chipGroup.js'; import { syncRowList } from './rowList.js'; import { layerFeedState } from '../data/feedState.js'; export { layerFeedState } from '../data/feedState.js'; import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; import { keySetupRequirement } from '../keySetupCore.mjs'; import { createWeatherPanel } from './weatherPanel.js'; const FEED_STATE_LABELS = Object.freeze({ nominal: 'ON', loading: 'LOADING', degraded: 'DEGRADED', stale: 'STALE', partial: 'PARTIAL', fallback: 'FALLBACK', unavailable: 'UNAVAILABLE', }); // Presentation order is independent of catalog registration and startup order. const PANEL_GROUPS = [ { label: 'Movement', ids: [ 'satellites', 'flights', 'military', 'ais-live-vessels', 'traffic', 'transit', 'bikeshare', ], }, { label: 'Cameras', ids: ['cctv', 'alpr-cameras'], }, { label: 'Infrastructure', ids: [ 'military-installations', 'local-datacenters', 'telegeography-submarine-cables', 'local-dams', ], }, { label: 'Events', ids: ['rocket-launches', 'earthquakes', 'local-firms'], }, { label: 'Weather', ids: [ 'wind', 'weather-radar', 'weather-satellite', 'weather-lightning', 'weather-cyclones', ], }, { label: 'Utilities', ids: ['directions', 'radio'], }, ]; const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) => ids.map((id) => ({ id, label })), ); const PANEL_POSITIONS = new Map( PANEL_ORDER.map(({ id }, index) => [id, index]), ); const PANEL_LABELS = { 'ais-live-vessels': 'Live Vessels', bikeshare: 'Bike Share', cctv: 'Cameras', 'alpr-cameras': 'Mapped ALPR Cameras', 'local-datacenters': 'Data Centers', 'local-firms': 'Active Fires', }; function panelLabel(layer) { return PANEL_LABELS[layer.id] || layer.name; } /** * Guidance for a control a missing provider key is holding back. * * The key registry already owns what each key is called and which environment * variables enable it, so a layer only declares WHICH key it needs * (`requiresKeyId`) and reports `stats.keyRequired` while that key is absent. * Naming the variable turns an unexplained dead control into a next step. * * An unnamed or unknown key returns '' rather than guessing: guidance naming * the wrong variable sends the operator to the wrong provider. * * @param {object} [layer] Row from the layer manager's getAll(). * @returns {string} Guidance text, or '' when no key guidance applies. */ export function layerKeyRequirementTooltip(layer = {}) { if (layer?.stats?.keyRequired !== true) return ''; const requiresKeyId = String(layer.requiresKeyId || '').trim(); return requiresKeyId ? keySetupRequirement(requiresKeyId) : ''; } /** Layer row presentation over supplied state and actions; no layer imports. */ export class LayerPanel { constructor({ getLayers, isEnabled, setEnabled, setLayerParams, getRowControls, hasRowControls, subscribeRowControls, onHiddenRefresh = () => {}, weatherClock, }) { this.weatherClock = weatherClock; 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; this._cancelRowControlsRefresh = null; } mount(container) { if (this._destroyed) return; this._releaseBindings(); this._toggleContainer = container; this._weatherPanel?.destroy(); this._weatherPanel = createWeatherPanel({ clock: this.weatherClock, container: container?.ownerDocument?.getElementById?.('weather-panel-body'), setLayerParams: this.setLayerParams, }); this._renderToggles(); } _bind(element, type, listener) { element.addEventListener(type, listener); this._removers.push(() => element.removeEventListener(type, listener)); } _releaseBindings() { this._generation++; this._cancelRowControlsRefresh?.(); this._cancelRowControlsRefresh = null; for (const remove of this._removers.splice(0)) remove(); } destroy() { if (this._destroyed) return; this._destroyed = true; this._releaseBindings(); this._weatherPanel?.destroy(); this._weatherPanel = null; this._toggleContainer = null; } _renderToggles() { if (this._destroyed || !this._toggleContainer) return; this._releaseBindings(); this._toggleContainer.innerHTML = ''; const generation = this._generation; const layers = this.getAll() .slice() .sort( (a, b) => (PANEL_POSITIONS.get(a.id) ?? PANEL_ORDER.length) - (PANEL_POSITIONS.get(b.id) ?? PANEL_ORDER.length), ); let previousGroup = ''; for (const layer of layers) { if (!layer.showInTogglePanel) continue; const group = PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? 'Other layers'; if (group && group !== previousGroup) { const heading = document.createElement('h3'); heading.className = 'data-layer-group-heading'; heading.textContent = group; this._toggleContainer.appendChild(heading); } previousGroup = group; 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 = panelLabel(layer); 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 = this._layerCountText(layer.stats); 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); } }); const readout = Boolean(this._rowControlsFor(layer.id)?.readout); if (!readout) 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._scheduleRowControlsRefresh(), ); if (unsubscribe) this._removers.push(unsubscribe); if (!readout) { 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 || chip.disabled || !this.isEnabled(layer.id)) return; if (typeof chip.onClick === 'function') chip.onClick(); else if (chip.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' }); }); row.appendChild(controls); // An ordered list below the chips, for a layer whose row carries a // sequence (turn-by-turn directions). Its own delegated listener, its // own container — the chip row stays a chip row. const list = document.createElement('ol'); list.className = 'data-row-list'; list.hidden = true; this._bind(list, 'click', (event) => { const button = event.target?.closest?.('.data-row-list-item'); if (!button || button.disabled) return; const item = this._rowControlsFor(layer.id)?.list?.items?.find( (entry) => entry.id === button.dataset.listItemId, ); if (item?.params) this.setLayerParams(layer.id, item.params, { origin: 'user' }); }); row.appendChild(list); this._syncRowControls(controls, layer, list); } } this._toggleContainer.appendChild(row); } this._refreshWeatherPanel(); } _scheduleRowControlsRefresh() { if (this._destroyed || this._cancelRowControlsRefresh) return; const refresh = () => { this._cancelRowControlsRefresh = null; if (!this._destroyed) this._refreshTogglePanel(); }; if (typeof requestAnimationFrame === 'function') { const frame = requestAnimationFrame(refresh); this._cancelRowControlsRefresh = () => cancelAnimationFrame(frame); } else { const timer = setTimeout(refresh, 0); this._cancelRowControlsRefresh = () => clearTimeout(timer); } } /** Synchronously flush a pending row-controls refresh, including in tests. */ _flushRowControlsRefresh() { if (this._destroyed || !this._cancelRowControlsRefresh) return; this._cancelRowControlsRefresh(); this._cancelRowControlsRefresh = null; this._refreshTogglePanel(); } _refreshWeatherPanel() { this._weatherPanel?.update( this.getAll() .filter( (layer) => layer.enabled && [ 'wind', 'weather-radar', 'weather-satellite', 'weather-lightning', 'weather-cyclones', ].includes(layer.id), ) .map((layer) => ({ id: layer.id, icon: layer.icon, ...this._rowControlsFor(layer.id), })), ); } /** Qualify a loaded count when it does not mean items currently on screen. */ _layerCountText(stats) { if (typeof stats.countLabel === 'string' && stats.countLabel.trim()) return stats.countLabel; return stats.count ? this._formatCount(stats.count) : '—'; } /** * 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 are rebuilt only when their content changes; * the info node is retained across refreshes. * @param {HTMLElement|null} container The row's `.data-toggle-controls` node. * @param {object} layer Registered layer entry. * @param {HTMLElement|null} [listContainer] The row's `.data-row-list` node. */ _syncRowControls(container, layer, listContainer = null) { if (!container) return; const controls = layer.enabled ? this._rowControlsFor(layer.id) : null; if (controls?.readout) { container.remove(); listContainer?.remove(); return; } const chips = controls?.chips || []; const legend = controls?.legend || []; const infoText = controls?.info; this._syncRowList(listContainer, controls?.list || null); container.hidden = chips.length === 0 && legend.length === 0 && !infoText; const info = container._rowControlsInfo || null; const firstLegend = container.querySelector('.data-toggle-legend-item'); syncChipGroup(container, chips, { before: firstLegend || info }); const legendSignature = JSON.stringify( legend.map(({ label, color, count, blurb }) => [ label, color, count, blurb, ]), ); if (container._legendSignature !== legendSignature) { for (const node of [...container.children]) { if (node.className === 'data-toggle-legend-item') 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.count == null ? String(item.label) : `${item.label} ${this._formatCount(item.count)}`; entry.append(swatch, text); container.insertBefore(entry, info); } container._legendSignature = legendSignature; } if (infoText || info) { const node = info || document.createElement('div'); if (!info) { container.appendChild(node); container._rowControlsInfo = node; } const className = 'data-toggle-controls-info'; if (node.className !== className) node.className = className; const text = infoText ? String(infoText) : ''; const title = controls?.infoTitle || ''; if (node.textContent !== text) node.textContent = text; if (node.title !== title) node.title = title; const hidden = !text; if (node.hidden !== hidden) node.hidden = hidden; } } /** * Render a row's ordered list (turn-by-turn directions). * * Each entry is a real `