feat(ui): render the Weather readout as one component

createWeatherPanel() computes the readout it shows (forecast and event
cards, the observed group's scope, the history timeline state and observed
cards) and renders WeatherReadout synchronously into the panel body, so a
newly opened card is still revealed after measuring. The hand-built hosts
and the separate card and timeline builders are gone; markup, open-card
choice, first appearance and teardown are unchanged.
This commit is contained in:
Sameh Khamis committed 2026-10-30 18:48:46 -07:00
1 parent ce6040bde1
commit a318376203
4 files changed
+103 -63

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
<script setup>
import { t } from '../../i18n/index.js';
import RailCards from '../rail/RailCards.vue';
import RailTimeline from '../rail/RailTimeline.vue';
// Active weather: forecast and event cards, then the observed products with
// their shared history timeline. The owner computes every value shown.
defineProps({
view: { type: Object, required: true },
onParams: { type: Function, default: () => {} },
onOpen: { type: Function, default: () => {} },
transport: { type: Object, required: true },
});
</script>
<template>
<section
class="weather-readout"
:hidden="view.hidden"
:aria-label="t('Active weather')"
><div class="weather-cards"
><RailCards
:cards="view.cards"
card-class-name="weather-card"
badge-class-name="weather-coverage"
:on-params="onParams"
:on-open="onOpen" /></div
><section
class="weather-observed-group"
:hidden="!view.observed"
:aria-label="t('Observed history')"
><h3 class="panel-title">{{ t('Observed history') }}</h3
><div class="weather-observed-scope">{{ view.scope }}</div
><div class="weather-timeline-block" :hidden="!view.observed"
><RailTimeline
:state="view.timeline"
slider-class-name="weather-timeline"
:heading="false"
:on-commit="transport.onCommit"
:on-preview="transport.onPreview"
:on-step="transport.onStep"
:on-latest="transport.onLatest"
:on-play="transport.onPlay" /></div
><div class="weather-cards"
><RailCards
:cards="view.observedCards"
card-class-name="weather-card"
badge-class-name="weather-coverage"
:on-params="onParams"
:on-open="onOpen" /></div></section
></section>
</template>
+43 -59
View File
@@ -1,6 +1,7 @@
import { h, render as renderVue } from 'vue';
import { formatUtcDayMonth, t } from '../i18n/index.js';
import { createRailCards } from './railCards.js';
import { createRailTimeline, utcClock } from './railTimeline.js';
import { utcClock } from './rail/utcClock.js';
import WeatherReadout from './weather/WeatherReadout.vue';
// Remounting controls must not reopen a panel the user already collapsed.
// Keyed by the page's Weather panel, which outlives remounted controls.
@@ -70,28 +71,6 @@ export function createWeatherPanel({
} = {}) {
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 = [];
@@ -102,32 +81,38 @@ export function createWeatherPanel({
let openId = openPanels.get(page) || 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',
const handlers = {
onParams: (id, params) => setLayerParams(id, params, { origin: 'user' }),
onOpen: (id) => {
openId = id;
openPanels.set(page, id);
render();
},
transport: {
onCommit: (tick) => clock?.setTarget(tick),
onPreview: historyTime,
onStep: (direction) => clock?.step(direction),
onLatest: () => clock?.latest(),
onPlay: () => clock?.togglePlay(),
},
};
const cards = createRailCards({ ...cardOptions, container: cardsHost });
const observedCards = createRailCards({
...cardOptions,
container: observedCardsHost,
// The readout patches synchronously, so a newly opened card can be measured.
const paint = (view) =>
renderVue(h(WeatherReadout, { view, ...handlers }), container);
paint({
hidden: true,
observed: false,
scope: '',
timeline: {
ticks: [],
index: 0,
mode: 'latest',
playing: false,
readout: '',
disabled: true,
},
cards: [],
observedCards: [],
});
const render = () => {
if (destroyed) return;
@@ -141,14 +126,11 @@ export function createWeatherPanel({
.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
@@ -158,7 +140,7 @@ export function createWeatherPanel({
(tick) => Date.parse(tick) <= Date.parse(state.target),
),
);
timeline.update({
const timeline = {
ticks: state.timeline,
index,
mode: state.mode,
@@ -168,7 +150,7 @@ export function createWeatherPanel({
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.
@@ -248,18 +230,23 @@ export function createWeatherPanel({
blocks,
};
});
cards.update(models.filter(({ id }) => !OBSERVED.has(id)));
observedCards.update(models.filter(({ id }) => OBSERVED.has(id)));
const hidden = active.length === 0;
paint({
hidden,
observed: showTimeline,
scope: observed.map(({ id }) => names[id]).join(' · '),
timeline,
cards: models.filter(({ id }) => !OBSERVED.has(id)),
observedCards: 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,
);
const open = [
...container.querySelectorAll('.weather-cards > [data-card-id]'),
].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 && !appearedPanels.has(panel)) {
@@ -305,10 +292,7 @@ export function createWeatherPanel({
destroy() {
destroyed = true;
unsubscribe?.();
timeline.destroy();
cards.destroy();
observedCards.destroy();
root.remove();
renderVue(null, container);
if (panel) set(panel, 'hidden', true);
if (count) set(count, 'textContent', '0');
},