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 { keySetupRequirementEnvVars } from '../keySetupCore.mjs'; import { createWeatherPanel } from './weatherPanel.js'; import { t, msg } from '../i18n/index.js'; // Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA // hooks, CSS feed-* classes); only the final label value translates, at this // presentation boundary. See docs/TRANSLATORS.md keep-English boundary. const FEED_STATE_LABEL_KEYS = Object.freeze({ nominal: msg('ON'), loading: msg('LOADING'), degraded: msg('DEGRADED'), stale: msg('STALE'), partial: msg('PARTIAL'), fallback: msg('FALLBACK'), unavailable: msg('UNAVAILABLE'), }); // Layer display names, localized at the same presentation boundary. Registry // ids stay English; a layer without an entry here renders its module name, // falling back to the English panel override when one exists. const LAYER_NAME_KEYS = Object.freeze({ flights: msg('Live Flights'), military: msg('Military Flights'), earthquakes: msg('Earthquakes (24h)'), satellites: msg('Satellites'), 'rocket-launches': msg('Space Missions (30d)'), traffic: msg('Street Traffic'), cctv: msg('CCTV'), radio: msg('Radio'), bikeshare: msg('Bikeshare'), 'ais-live-vessels': msg('Live AIS Vessels'), 'military-installations': msg('Mapped Installations'), 'military-awareness': msg('Global Context'), 'local-datacenters': msg('Datacenters'), 'local-dams': msg('Dams'), 'telegeography-submarine-cables': msg('Submarine Cables'), 'local-firms': msg('FIRMS Active Fires'), 'local-adsb': msg('Local ADS-B'), transit: msg('Transit'), 'recent-imagery': msg('Recent Imagery'), 'fire-perimeters': msg('Fire Perimeters'), wind: msg('Wind'), 'weather-radar': msg('Rain radar'), 'weather-satellite': msg('Satellite clouds'), 'weather-lightning': msg('Lightning density'), 'weather-cyclones': msg('Cyclone advisories'), directions: msg('Directions'), }); function lifecycleStateLabel(lifecycleState) { return t(lifecycleState === 'enabling' ? msg('ENABLING') : msg('DISABLING')); } 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: msg('Movement'), ids: [ 'satellites', 'flights', 'military', 'local-adsb', 'ais-live-vessels', 'traffic', 'transit', 'bikeshare', ], }, { label: msg('Cameras'), ids: ['cctv', 'recent-imagery', 'street-level'], }, { label: msg('Infrastructure'), ids: [ 'alpr-cameras', 'military-installations', 'local-datacenters', 'telegeography-submarine-cables', 'local-dams', ], }, { label: msg('Events'), ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'], }, { label: msg('Weather'), ids: [ 'wind', 'weather-radar', 'weather-satellite', 'weather-lightning', 'weather-cyclones', ], }, { label: msg('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': msg('Live Vessels'), bikeshare: msg('Bike Share'), cctv: msg('Cameras'), 'street-level': msg('Street Level'), 'alpr-cameras': msg('Mapped ALPR Cameras'), 'local-datacenters': msg('Data Centers'), 'local-firms': msg('Active Fires'), }; function panelLabel(layer) { const key = LAYER_NAME_KEYS[layer.id]; if (key) return t(key); return PANEL_LABELS[layer.id] ? t(PANEL_LABELS[layer.id]) : layer.name; } // English module names (layer.name) that other surfaces print, such as the // loading chip's layer roster. Looked up by the English text so that surface // keeps its English output and only the presentation translates. const MODULE_NAME_KEYS = new Map( [ ...Object.values(LAYER_NAME_KEYS), ...Object.values(PANEL_LABELS), msg('ALPR Cameras'), ].map((key) => [key, key]), ); /** * Translate a layer's English module name for display; any other text * (a provider name, an unknown layer) is returned unchanged. * @param {string} name English `layer.name`. * @returns {string} */ export function localizeLayerName(name) { const key = MODULE_NAME_KEYS.get(String(name ?? '').trim()); return key ? t(key) : name; } // Layer-declared source names stay English in layer state, share links and AI // tool output; only the row's source line translates them. Provider names // (USGS, CelesTrak, GTFS-RT…) are not listed and pass through unchanged. const SOURCE_LABEL_KEYS = new Map( [ msg('Flights'), msg('Military aircraft'), msg('Vessels'), msg('CCTV + Street View fallback'), msg('OpenStreetMap + optional Google Maps Places'), msg('GFS / ECMWF IFS · FORECAST'), msg('NOAA nowCOAST · OBSERVED'), msg('NASA FIRMS · LIVE'), msg('OSM routing'), ].map((key) => [key, key]), ); function sourceLabel(source) { if (typeof source !== 'string') return source; if (source === 'Local') return t('Local', { context: 'data source' }); const key = SOURCE_LABEL_KEYS.get(source); return key ? t(key) : source; } /** * 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(); const envVars = requiresKeyId ? keySetupRequirementEnvVars(requiresKeyId) : null; return envVars ? t('Needs {envVars} — add it in Provider Settings', { envVars }) : ''; } const ROW_TEXT_SEPARATOR = ' · '; // A short Latin code joined by hyphens. Longer runs (a URL, a token in an // error) keep their break opportunities so they cannot overflow the rail. const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g; const HYPHENATED_CODE_MAX = 16; /** A span for one run of row text; `keep` stops the browser breaking inside it. */ function rowTextSpan(text, keep) { const span = document.createElement('span'); if (keep) span.className = 'data-keep'; span.textContent = text; return span; } /** * Set a row's name, count or meta line so that a narrow column wraps it well * in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never * splits at its hyphen, and a " · " separator stays at the end of the line it * closes, so no line starts with "·". The element's textContent is still the * plain string; text with neither feature is set directly. * * @param {HTMLElement} element * @param {string} text */ export function setRowText(element, text) { const value = String(text ?? ''); // Refreshes run every second; leave unchanged text (and its spans) alone. if (element.textContent === value) return; const codes = [...value.matchAll(HYPHENATED_CODE)].filter( (match) => match[0].length <= HYPHENATED_CODE_MAX, ); if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) { element.textContent = value; return; } const spans = []; const pushPlain = (from, to) => { if (to <= from) return; const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR); pieces.forEach((piece, index) => { // The space before the dot belongs to a no-wrap span, so the only break // opportunity around a separator is the space after it. if (index > 0) spans.push(rowTextSpan(' ·', true)); const textPiece = index > 0 ? ` ${piece}` : piece; if (textPiece) spans.push(rowTextSpan(textPiece, false)); }); }; let cursor = 0; for (const match of codes) { pushPlain(cursor, match.index); spans.push(rowTextSpan(match[0], true)); cursor = match.index + match[0].length; } pushPlain(cursor, value.length); element.textContent = ''; for (const span of spans) element.appendChild(span); } /** 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; this._recentImageryFactory = null; this._recentImageryPanel = 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._mountRecentImagery(); this._renderToggles(); } /** * Host the Recent Imagery readout in its rail body, like the weather * readout. The application supplies the factory once the layer, viewer and * box tool exist; a remount rebuilds the readout in place. * @param {((container: HTMLElement) => { destroy: () => void } | null) | null} factory */ attachRecentImagery(factory) { if (this._destroyed) return; this._recentImageryFactory = typeof factory === 'function' ? factory : null; this._mountRecentImagery(); } _mountRecentImagery() { this._recentImageryPanel?.destroy(); this._recentImageryPanel = null; const container = this._toggleContainer?.ownerDocument?.getElementById?.( 'recent-imagery-panel-body', ); if (container && this._recentImageryFactory) this._recentImageryPanel = this._recentImageryFactory(container) || null; } _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._recentImageryPanel?.destroy(); this._recentImageryPanel = null; this._recentImageryFactory = 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 ?? msg('Other layers'); if (group && group !== previousGroup) { const heading = document.createElement('h3'); heading.className = 'data-layer-group-heading'; heading.textContent = t(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.setAttribute('aria-hidden', 'true'); icon.textContent = layer.icon; const name = document.createElement('span'); name.className = 'data-name'; setRowText(name, 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'; this._syncCount(row, count, 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'; setRowText(bottomRow, 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), })), ); } /** * Write a row's count. A count that is a phrase carrying its own number * ("24 nearby · 2 on screen") moves to its own line under the name: beside * the toggle it squeezes the name to a word per line in any language. The * "—" placeholder is marked so it can give way to a wide status chip. */ _syncCount(row, count, stats) { const text = this._layerCountText(stats); setRowText(count, text); const phrase = typeof stats.countLabel === 'string' && /\d/.test(stats.countLabel); row.classList.toggle('data-toggle-row--count-line', phrase); count.classList.toggle('data-count-empty', text === '—'); } /** 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 `