mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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.
315 lines
11 KiB
JavaScript
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');
|
|
},
|
|
};
|
|
}
|