mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view. Co-authored-by: Gustavo Beneduzi <[email protected]> Co-authored-by: Daniel Slay <[email protected]> Co-authored-by: Rehaan Delmotra <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
297 lines
11 KiB
JavaScript
297 lines
11 KiB
JavaScript
import { createRailCards } from './railCards.js';
|
|
import { createRailTimeline } 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',
|
|
];
|
|
const OBSERVED = new Set(ORDER.slice(2));
|
|
const utc = (time) =>
|
|
Number.isFinite(Date.parse(time))
|
|
? `${new Date(time).toISOString().slice(5, 16).replace('T', ' ')} UTC`
|
|
: 'Unavailable';
|
|
const age = (time) => {
|
|
const minutes = Math.max(
|
|
0,
|
|
Math.floor((Date.now() - Date.parse(time)) / 60_000),
|
|
);
|
|
return minutes >= 60
|
|
? `${Math.floor(minutes / 60)}h ${minutes % 60}m ago`
|
|
: `${minutes}m ago`;
|
|
};
|
|
const historyTime = (time) =>
|
|
`${utc(time).slice(6)} · ${Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60_000))} min ago`;
|
|
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', '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', 'Observed history');
|
|
const heading = document.createElement('h3');
|
|
heading.className = 'panel-title';
|
|
heading.textContent = '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': 'Rain radar',
|
|
'weather-satellite': 'Satellite clouds',
|
|
'weather-lightning': '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'
|
|
? 'LATEST · newest per product'
|
|
: historyTime(state.target),
|
|
});
|
|
const models = active.map(({ id, icon, summary, legend = [], list }) => {
|
|
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;
|
|
detail = `No frame within ${gap < 60 ? `${gap} min` : `${gap / 60} h`} of ${utc(state.target).slice(6)}`;
|
|
} else if (shown) {
|
|
detail = dated(shown);
|
|
if (state.mode === 'history')
|
|
detail +=
|
|
Date.parse(shown) === Date.parse(state.target)
|
|
? ' · synced'
|
|
: ' · nearest';
|
|
}
|
|
} else if (id === 'wind') {
|
|
detail = `Forecast · valid ${utc(summary.validTime)} · issued ${utc(summary.issuedTime)}`;
|
|
if (state.mode === 'history') detail += ' · Does not follow history';
|
|
}
|
|
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: id === 'weather-cyclones',
|
|
colors: legend.map(({ color }) => color),
|
|
labels: legend.map(({ label }) => label),
|
|
units: id === 'weather-cyclones' ? '' : summary.units,
|
|
zeroIndex: legend.findIndex(({ label }) => label === '0'),
|
|
},
|
|
};
|
|
const blocks = [
|
|
...(id === 'weather-cyclones' && list?.items?.length
|
|
? [{ id: 'storms', 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'
|
|
? `Forecast · 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');
|
|
},
|
|
};
|
|
}
|