mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Mark every UI string the unmarked-text check reported (534) plus text it cannot see (helpers, joined labels, runtime-built panels), and translate the result to Spanish and Russian: 1,679 messages each. - Kept on purpose, with the reason where the text lives: provider and product names, place and camera names, codes and units, dB values, the HUD terminal readouts, demo shot titles that saved projects look up by title, and text for AI tools and the voice model. - Modules shared with the server or AI tools stay free of the i18n import (local msg); the wind reading and voice session stay English and are translated where the page shows them. - npm test now runs the unmarked-text check. - Fixes on the way: a duplicated radio privacy paragraph, the contact panel toggle saying Expand while open, the CSS filter mistaking & for CSS. Co-Authored-By: Claude Opus 5.5 <[email protected]>
759 lines
27 KiB
JavaScript
759 lines
27 KiB
JavaScript
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;
|
|
}
|
|
|
|
/**
|
|
* 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 })
|
|
: '';
|
|
}
|
|
|
|
/** 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';
|
|
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 `<button>` inside a real `<li>`, so Tab reaches it and
|
|
* Enter activates it with no key handling of our own, and the `<ol>` carries
|
|
* the ordering a screen reader announces. Items are reconciled in place,
|
|
* keyed by id, for the same reason chips are: this runs on every refresh —
|
|
* including the one a click on the list triggers — and replacing the node
|
|
* would drop keyboard focus mid-interaction.
|
|
* @param {HTMLElement|null} container The row's `.data-row-list` node.
|
|
* @param {{ariaLabel?: string, items?: Array<object>}|null} list Descriptor.
|
|
*/
|
|
_syncRowList(container, list) {
|
|
syncRowList(container, list);
|
|
}
|
|
|
|
_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 = this._layerCountText(layer.stats);
|
|
}
|
|
|
|
const meta = row.querySelector('.data-toggle-meta');
|
|
if (meta) {
|
|
meta.textContent = this._buildMetaText(layer);
|
|
}
|
|
|
|
this._syncRowControls(
|
|
row.querySelector('.data-toggle-controls'),
|
|
layer,
|
|
row.querySelector('.data-row-list'),
|
|
);
|
|
}
|
|
this._refreshWeatherPanel();
|
|
}
|
|
|
|
_buildMetaText(layer) {
|
|
const stats = layer.stats || {};
|
|
const feedState = layerFeedState(stats);
|
|
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]);
|
|
const source = stats.source || layer.source;
|
|
const lifecycleState =
|
|
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
|
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
|
return t('{state} · {source}', {
|
|
state: lifecycleStateLabel(lifecycleState),
|
|
source,
|
|
});
|
|
}
|
|
if (layer.lifecycleUncertain) {
|
|
return t(
|
|
'UNCERTAIN · {source} · lifecycle state requires reconciliation',
|
|
{ source },
|
|
);
|
|
}
|
|
const presentedError =
|
|
stats.error || stats.lastError || stats.managerRefreshError;
|
|
if (presentedError) {
|
|
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
|
return t('{state} · {source} · {detail} · retry {seconds}s', {
|
|
state: stateLabel,
|
|
source,
|
|
detail: presentedError,
|
|
seconds: stats.retryInSec,
|
|
});
|
|
}
|
|
return t('{state} · {source} · {detail}', {
|
|
state: stateLabel,
|
|
source,
|
|
detail: 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) : t('never');
|
|
if (stats.loading) {
|
|
const loadingLabel =
|
|
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
|
? stats.loadingLabel.trim()
|
|
: t('loading...');
|
|
return t('{source} · {loadingLabel}', { source, loadingLabel });
|
|
}
|
|
if (feedState === 'fallback') {
|
|
const detail =
|
|
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
|
|
? stats.loadingLabel.trim()
|
|
: stats.coverage || ago;
|
|
return t('{state} · {source} · {detail}', {
|
|
state: stateLabel,
|
|
source,
|
|
detail,
|
|
});
|
|
}
|
|
if (feedState === 'partial') {
|
|
const { acceptedRowCount, rawRowCount } = stats;
|
|
const detail =
|
|
Number.isInteger(acceptedRowCount) &&
|
|
Number.isInteger(rawRowCount) &&
|
|
acceptedRowCount >= 0 &&
|
|
rawRowCount > acceptedRowCount
|
|
? t('{accepted} of {raw} records accepted', {
|
|
accepted: acceptedRowCount,
|
|
raw: rawRowCount,
|
|
})
|
|
: t('incomplete snapshot');
|
|
return t('{state} · {source} · {detail} · {ago}', {
|
|
state: stateLabel,
|
|
source,
|
|
detail,
|
|
ago,
|
|
});
|
|
}
|
|
if (feedState === 'stale') {
|
|
if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) {
|
|
return t('{state} · {source} · {ago} · retrying in {seconds}s', {
|
|
state: stateLabel,
|
|
source,
|
|
ago,
|
|
seconds: stats.retryInSec,
|
|
});
|
|
}
|
|
return t('{state} · {source} · {ago}', {
|
|
state: stateLabel,
|
|
source,
|
|
ago,
|
|
});
|
|
}
|
|
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
|
|
return t('{source} · {loadingLabel}', {
|
|
source,
|
|
loadingLabel: stats.loadingLabel.trim(),
|
|
});
|
|
}
|
|
return t('{source} · {ago}', { source, ago });
|
|
}
|
|
|
|
_syncToggleButton(button, layer) {
|
|
const unavailable = layer.stats?.sourceUnavailable === true;
|
|
const feedState = unavailable
|
|
? 'unavailable'
|
|
: 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 || unavailable) && !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 || (unavailable && !layer.enabled)),
|
|
);
|
|
button.setAttribute('aria-busy', String(transitioning));
|
|
button.textContent = transitioning
|
|
? lifecycleStateLabel(layer.lifecycleState)
|
|
: uncertain
|
|
? t('UNCERTAIN')
|
|
: layer.enabled || unavailable
|
|
? t(FEED_STATE_LABEL_KEYS[feedState])
|
|
: t('OFF');
|
|
const keyGuidance = unavailable
|
|
? String(layer.stats.error || t('Data source unavailable'))
|
|
: layerKeyRequirementTooltip(layer);
|
|
// Name the missing key on the control itself: a row reading KEY REQUIRED
|
|
// without saying WHICH key leaves a dead control and no next step. Empty
|
|
// when the layer needs no key, or already has one.
|
|
button.title = keyGuidance;
|
|
const ariaLabel = t('{name}: {state}', {
|
|
name: panelLabel(layer),
|
|
state: button.textContent,
|
|
});
|
|
button.setAttribute(
|
|
'aria-label',
|
|
keyGuidance ? `${ariaLabel}. ${keyGuidance}` : ariaLabel,
|
|
);
|
|
}
|
|
|
|
_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 t('just now');
|
|
if (diff < 60) return t('{count}s ago', { count: diff });
|
|
if (diff < 3600) return t('{count}m ago', { count: Math.floor(diff / 60) });
|
|
return t('{count}h ago', { count: Math.floor(diff / 3600) });
|
|
}
|
|
}
|