import { layerFeedState } from '../data/feedState.js'; export { layerFeedState } from '../data/feedState.js'; import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; import { keySetupRequirement } from '../keySetupCore.mjs'; const FEED_STATE_LABELS = Object.freeze({ nominal: 'ON', loading: 'LOADING', degraded: 'DEGRADED', stale: 'STALE', fallback: 'FALLBACK', unavailable: 'UNAVAILABLE', }); /** * 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 = () => {}, }) { 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 = 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); } }); 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 || 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); } } /** 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 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. * @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; const chips = controls?.chips || []; const legend = controls?.legend || []; this._syncRowList(listContainer, controls?.list || null); 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); } } /** * Render a row's ordered list (turn-by-turn directions). * * Each entry is a real `