Files
gods-eye-view/src/ui/weatherPanel.js
T
Meir Michanie f3da0916d9 Merge upstream/main: air-quality layer (AQHI) meets i18n
Upstream's #568 adds the Air quality layer to the Weather group (ECCC
AQHI, Canada only) with a band legend, worst-first list and voice
support.

- package-boundaries: both sides' entries unioned.
- layerPanel/weatherPanel tests: both sides' new tests kept; the
  weatherPanel fixture follows upstream (legendCategorical: true, the
  'list' block id) while our localized-history (K-6) test stays.
- air-quality strings marked: band labels and the provider label as
  msg() data, the readings-list aria-label through t(); es/ru entries
  added (+6 keys each).
- The synthetic air-quality allocation-benchmark titles follow the
  file's own convention (// i18n-ignore) like TRK/VESSEL/CAMERA.

Also verified the auto-merged weatherPanel.js against upstream: ORDER
gains 'air-quality', OBSERVED stays slice(2, 5), and the data-driven
legendCategorical path is upstream's.

npm run i18n: complete (2559), no unmarked strings. npm test: only the
pre-existing environmental OPENAI_API_KEY leak fails. Build passes.
2026-10-15 23:16:24 +02:00

315 lines
11 KiB
JavaScript

import { formatUtcDayMonth, t } from '../i18n/index.js';
import { createRailCards } from './railCards.js';
import { createRailTimeline, utcClock } from './railTimeline.js';
// Remounting controls must not reopen a panel the user already collapsed.
const appearedDocuments = new WeakSet();
const openDocuments = new WeakMap();
const ORDER = [
'weather-cyclones',
'wind',
'weather-radar',
'weather-satellite',
'weather-lightning',
'air-quality',
];
const OBSERVED = new Set(ORDER.slice(2, 5));
const utc = (time) =>
Number.isFinite(Date.parse(time))
? `${formatUtcDayMonth(time)} ${utcClock(time)}`
: t('Unavailable');
const clockTime = (time) => utcClock(time) || t('Unavailable');
const age = (time) => {
const minutes = Math.max(
0,
Math.floor((Date.now() - Date.parse(time)) / 60_000),
);
return minutes >= 60
? t('{hours}h {minutes}m ago', {
hours: Math.floor(minutes / 60),
minutes: minutes % 60,
})
: t('{minutes}m ago', { minutes });
};
const historyTime = (time) =>
t('{time} · {minutes} min ago', {
time: clockTime(time),
minutes: Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000)),
});
const dated = (time) => `${utc(time)} · ${age(time)}`;
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
/**
* Bring a newly opened card into the scrolled body: nearest alignment, or its
* header at the top when the card is taller than the body. One write to the
* body's own scroll position, without animation; ancestors never move.
*/
function revealCard(scroller, card) {
const height = scroller?.clientHeight;
if (!(height > 0) || !scroller.getBoundingClientRect) return;
if (typeof card?.getBoundingClientRect !== 'function') return;
const view = scroller.getBoundingClientRect();
const box = card.getBoundingClientRect();
const top = box.top - view.top - (scroller.clientTop || 0);
const delta =
top < 0 || box.height > height
? top
: Math.max(0, top + box.height - height);
if (delta) scroller.scrollTop = (scroller.scrollTop || 0) + delta;
}
/** Map weather descriptors and the observed clock into reusable rail readouts. */
export function createWeatherPanel({
container,
clock,
setLayerParams = () => {},
onAction = () => {},
} = {}) {
const document = container?.ownerDocument;
if (!document?.createElement) return null;
const root = document.createElement('section');
root.className = 'weather-readout';
root.hidden = true;
root.setAttribute('aria-label', t('Active weather'));
const timelineHost = document.createElement('div');
timelineHost.className = 'weather-timeline-block';
timelineHost.hidden = true;
const cardsHost = document.createElement('div');
cardsHost.className = 'weather-cards';
const observedGroup = document.createElement('section');
observedGroup.className = 'weather-observed-group';
observedGroup.setAttribute('aria-label', t('Observed history'));
const heading = document.createElement('h3');
heading.className = 'panel-title';
heading.textContent = t('Observed history');
const scope = document.createElement('div');
scope.className = 'weather-observed-scope';
const observedCardsHost = document.createElement('div');
observedCardsHost.className = 'weather-cards';
observedGroup.append(heading, scope, timelineHost, observedCardsHost);
root.append(cardsHost, observedGroup);
container.appendChild(root);
const panel = container.closest?.('#weather-panel');
const count = panel?.querySelector('#weather-panel-count');
let entries = [];
let destroyed = false;
let previousIds = null;
let hasAppeared = false;
let openId = openDocuments.get(document) || null;
// Open card as last shown; null until cards are on screen.
let shownOpenId = null;
const timeline = createRailTimeline({
container: timelineHost,
document,
sliderClassName: 'weather-timeline',
heading: false,
onCommit: (tick) => clock?.setTarget(tick),
onPreview: historyTime,
onStep: (direction) => clock?.step(direction),
onLatest: () => clock?.latest(),
onPlay: () => clock?.togglePlay(),
});
const cardOptions = {
document,
cardClassName: 'weather-card',
badgeClassName: 'weather-coverage',
onParams: (id, params) => setLayerParams(id, params, { origin: 'user' }),
onOpen: (id) => {
openId = id;
openDocuments.set(document, id);
render();
},
};
const cards = createRailCards({ ...cardOptions, container: cardsHost });
const observedCards = createRailCards({
...cardOptions,
container: observedCardsHost,
});
const render = () => {
if (destroyed) return;
const state = clock?.getState() || {
mode: 'latest',
timeline: [],
products: [],
};
const active = entries
.filter(({ summary }) => summary)
.sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
const observed = active.filter(({ id }) => OBSERVED.has(id));
const showTimeline = observed.length > 0;
set(observedGroup, 'hidden', !showTimeline);
const names = {
'weather-radar': t('Rain radar'),
'weather-satellite': t('Satellite clouds'),
'weather-lightning': t('Lightning density'),
};
set(scope, 'textContent', observed.map(({ id }) => names[id]).join(' · '));
set(timelineHost, 'hidden', !showTimeline);
const index =
state.mode === 'latest'
? state.timeline.length - 1
: Math.max(
0,
state.timeline.findLastIndex(
(tick) => Date.parse(tick) <= Date.parse(state.target),
),
);
timeline.update({
ticks: state.timeline,
index,
mode: state.mode,
playing: Boolean(state.playing),
disabled: !showTimeline || state.timeline.length < 2,
readout:
state.mode === 'latest'
? t('LATEST · newest per product')
: historyTime(state.target),
});
const models = active.map((entry) => {
const { id, icon, summary, legend = [], list } = entry;
// Discrete classes (storm geometry, AQHI bands) list swatches, not a ramp.
const categorical = entry.legendCategorical === true;
let detail = summary.detail;
const product = state.products.find((item) => item.id === id);
if (OBSERVED.has(id)) {
const shown = product?.shown ?? summary.shownTime;
if (state.mode === 'history' && product?.selected === null) {
const gap = summary.maxGapMinutes || 30;
const time = clockTime(state.target);
detail =
gap < 60
? t('No frame within {gap} min of {time}', { gap, time })
: t('No frame within {gap} h of {time}', { gap: gap / 60, time });
} else if (shown) {
detail = dated(shown);
if (state.mode === 'history')
detail =
Date.parse(shown) === Date.parse(state.target)
? t('{detail} · synced', { detail })
: t('{detail} · nearest', { detail });
}
} else if (id === 'wind') {
detail = t('Forecast · valid {valid} · issued {issued}', {
valid: utc(summary.validTime),
issued: utc(summary.issuedTime),
});
if (state.mode === 'history')
detail = t('{detail} · Does not follow history', { detail });
}
const lines = [
{ id: 'time', text: detail, muted: true },
{ id: 'status', text: summary.status },
];
for (const line of summary.lines || []) lines.push(line);
const actions = (summary.actions || []).map((action) => ({
...action,
onClick: action.onClick || (() => onAction(id, action.id)),
}));
const legendBlock = {
id: 'legend',
type: 'legend',
legend: {
categorical,
colors: legend.map(({ color }) => color),
labels: legend.map(({ label }) => label),
units: categorical ? '' : summary.units,
zeroIndex: legend.findIndex(({ label }) => label === '0'),
},
};
const blocks = [
...(list?.items?.length ? [{ id: 'list', type: 'list', list }] : []),
{ id: 'details', type: 'lines', lines },
...(legend.length ? [legendBlock] : []),
...(summary.settings?.length
? [{ id: 'settings', type: 'settings', settings: summary.settings }]
: []),
...(actions.length
? [{ id: 'actions', type: 'actions', actions }]
: []),
...(summary.result ? [{ ...summary.result, type: 'result' }] : []),
];
return {
id,
icon,
title: summary.label,
badge: summary.coverage,
open: id === openId,
compact:
summary.status ||
summary.compact ||
(id === 'wind'
? t('Forecast · valid {valid}', { valid: utc(summary.validTime) })
: detail),
compactStatus: Boolean(summary.status),
blocks,
};
});
cards.update(models.filter(({ id }) => !OBSERVED.has(id)));
observedCards.update(models.filter(({ id }) => OBSERVED.has(id)));
// Only a change of open card moves the scroll position; refreshes leave it.
if (shownOpenId !== null && openId !== shownOpenId) {
const open = [...cardsHost.children, ...observedCardsHost.children].find(
(node) => node.dataset?.cardId === openId,
);
revealCard(container, open);
}
shownOpenId = active.length ? openId : null;
const hidden = active.length === 0;
set(root, 'hidden', hidden);
if (count) set(count, 'textContent', String(active.length));
if (panel) set(panel, 'hidden', hidden);
if (!hidden && panel && !appearedDocuments.has(document)) {
appearedDocuments.add(document);
// First appearance opens the panel unless a stored or shared collapse
// choice exists (marked by the panel chrome when it restored the state).
const preference = panel.dataset?.collapsedPreference;
if (
panel.classList.contains('collapsed') &&
(preference === undefined || preference === 'default')
)
panel.querySelector('[data-collapse-target="weather-panel"]')?.click();
}
};
const unsubscribe = clock?.subscribe(render);
return {
update(nextEntries) {
entries = nextEntries.filter(({ summary }) => summary);
const ids = new Set(entries.map(({ id }) => id));
const ordered = [...entries].sort(
(a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id),
);
const added =
previousIds && entries.filter(({ id }) => !previousIds.has(id));
if (hasAppeared && added?.length) {
openId = added.at(-1).id;
openDocuments.set(document, openId);
} else if (ids.size && !ids.has(openId)) {
openId =
(!hasAppeared &&
!openId &&
ordered.find(
({ id, list }) =>
id === 'weather-cyclones' && list?.items?.length,
)?.id) ||
ordered[0]?.id ||
null;
}
if (ids.size) hasAppeared = true;
previousIds = ids;
render();
},
destroy() {
destroyed = true;
unsubscribe?.();
timeline.destroy();
cards.destroy();
observedCards.destroy();
root.remove();
if (panel) set(panel, 'hidden', true);
if (count) set(count, 'textContent', '0');
},
};
}