The main timeline is the only time control; the Weather panel shows its frame time

The Weather panel kept a second time transport of its own: a slider, step
buttons, Latest and Play, backed by a replay in the weather clock that
stepped the observed frames every two seconds and went round from the last
to the first. The weather clock also attached itself to the world clock as
a driver, so the timeline's Play became that replay whenever weather was on.

The panel now shows the frame time only: the newest frame and its age while
live, the timeline time alone at a past time. The weather clock no longer
has setTarget, step, latest, play, pause or togglePlay, its state no longer
has playing, and it no longer drives the world clock. It still follows the
world time, registers each product's frames as marks the timeline's arrow
keys step to, holds its products, and gates the world's continuous play at
each next frame until it loads. weatherClock.world names the world clock it
follows.

A weather row's play, latest and step params move the world clock as the
timeline does: play toggles continuous play (from the start of the covered
time when live), latest goes live, and step goes to the previous or next
mark. The rail timeline component, which only the panel used, is removed,
along with its unused messages in every locale.
This commit is contained in:
Sameh Khamis committed 2026-11-14 20:25:31 -08:00
1 parent bce737bf32
commit 3cfd414e70
35 files changed
+328 -1033

No files matched your search

@@ -0,0 +1,14 @@
- The main timeline is the only time control. The Weather panel no longer
has a slider, step buttons, Latest or Play of its own; it shows the frame
time instead: the newest frame and its age while live, the timeline time
alone at a past time. The timeline's Play runs the world clock at the
chosen speed for weather too, stopping at each next frame until it loads,
rather than stepping the observed frames every two seconds and going round
from the last to the first. A weather row's `play`, `latest` and `step`
params move the world clock as the timeline does (`step` goes to the
previous or next mark, as its arrow keys do). **Breaking:** the catalog's
`weatherClock` (`gods-eye-view/application/layers`) no longer has
`setTarget`, `step`, `latest`, `play`, `pause` or `togglePlay`, its state
no longer has `playing`, and it no longer attaches itself to the world
clock as a driver; move time with the world clock (`gods-eye-view/time`,
also `weatherClock.world`) instead (samehkhamis, #0000).
+4 -1
View File
@@ -187,7 +187,10 @@ instances. Configure sources with `application/sources` before registration or
state restoration, and release them after the consumers stop. The catalog state restoration, and release them after the consumers stop. The catalog
constructor (`application/layers`) also requires `wind`, `weather` and constructor (`application/layers`) also requires `wind`, `weather` and
`cyclones` sources with `getSnapshot` for the Weather layers, and owns the `cyclones` sources with `getSnapshot` for the Weather layers, and owns the
observed-history clock those layers share for the catalog's lifetime. observed-history clock those layers share for the catalog's lifetime. That
clock, the catalog's `weatherClock`, follows the world clock (`gods-eye-view/time`)
and has no time controls of its own: move time with the world clock's `seek`,
`play`, `pause` and `goLive` (`weatherClock.world` is the one it follows).
A `weather` source may also supply decoded frames: an optional A `weather` source may also supply decoded frames: an optional
`getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or `getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or
detail image and an optional `getFrameTile({ product, time, z, x, y, size, detail image and an optional `getFrameTile({ product, time, z, x, y, size,
+7 -8
View File
@@ -293,9 +293,9 @@ the user's later collapse choice, including body remounts. Mark the body
`max-height` and puts the scroll offset back afterwards. Panel bodies only `max-height` and puts the scroll offset back afterwards. Panel bodies only
scroll on an open-card change, with one write, never on a refresh. scroll on an open-card change, with one write, never on a refresh.
Compose `createRailCards({ container, document, onParams, onOpen })` and Compose `createRailCards({ container, document, onParams, onOpen })`. Rails
`createRailTimeline({ container, document, onCommit, onPreview, onStep, have no time controls of their own; the world timeline moves time. Cards
onLatest, onPlay })`. Cards reconcile `{ id, title, badge, open, compact, reconcile `{ id, title, badge, open, compact,
compactStatus, blocks }` in place. Their full-width native button headers expose compactStatus, blocks }` in place. Their full-width native button headers expose
`aria-expanded` and `aria-controls`; articles expose `data-open`. The feature `aria-expanded` and `aria-controls`; articles expose `data-open`. The feature
adapter owns accordion state. Generic cards do not select data or enable layers. adapter owns accordion state. Generic cards do not select data or enable layers.
@@ -330,10 +330,9 @@ consumers. They expose `summary.settings`, `summary.actions` and optional
subscriptions still refresh the cards. Cards own configuration and readings. subscriptions still refresh the cards. Cards own configuration and readings.
Weather orders cyclones, wind, then a bordered Observed history group containing Weather orders cyclones, wind, then a bordered Observed history group containing
the timeline and active radar, satellite and lightning cards. The group heading the frame time and active radar, satellite and lightning cards. The group
and scope name its active products. The timeline stays visible whenever any heading and scope name its active products. The frame time shows whenever any
observed product is enabled; fewer than two ticks disables transport. Pass observed product is enabled and has a frame; the world timeline moves time.
`heading: false` to the timeline when the containing group owns the heading.
Exactly one active card is open. Header clicks and newly enabled ids choose it Exactly one active card is open. Header clicks and newly enabled ids choose it
(the last new entry wins a batch); refreshes and clock ticks retain it. First (the last new entry wins a batch); refreshes and clock ticks retain it. First
appearance uses cyclones with storms, otherwise the first card. Disabling the appearance uses cyclones with storms, otherwise the first card. Disabling the
@@ -341,7 +340,7 @@ open layer falls back to the first remaining card. A WeakMap per document keeps
explicit choices through body remounts without adding share state. Card opening, explicit choices through body remounts without adding share state. Card opening,
storm selection and layer enabling remain independent. storm selection and layer enabling remain independent.
Optional card/badge/slider class names provide feature styling. Card titles use Optional card/badge class names provide feature styling. Card titles use
`.data-name` typography; meta/time/badges use `.data-toggle-meta` typography; `.data-name` typography; meta/time/badges use `.data-toggle-meta` typography;
setting/result labels use `.panel-title`; chips retain `.data-toggle-chip`. setting/result labels use `.panel-title`; chips retain `.data-toggle-chip`.
Reserve one status line, even without a legend. Use app tokens. Wind's footer Reserve one status line, even without a legend. Use app tokens. Wind's footer
+18 -10
View File
@@ -8532,9 +8532,10 @@ Wind supports selectable GFS/IFS models with persisted model choice. Each fetch
Weather combines the GFS/ECMWF surface forecast with three observation layers: Weather combines the GFS/ECMWF surface forecast with three observation layers:
NOAA Rain radar (CONUS), Satellite clouds (regional GOES or slower global infrared), NOAA Rain radar (CONUS), Satellite clouds (regional GOES or slower global infrared),
and 15-minute lightning density (Americas and Pacific). One transient observed and 15-minute lightning density (Americas and Pacific). One transient observed
clock drives all enabled observation rows. Earlier/Later use the sorted union of clock moves all enabled observation rows with the world clock; the world
non-suspended products' advertised times; each product selects its nearest frame timeline is the only time control. Each product's advertised times are marks on
at or before the requested UTC time, never a future frame or an interpolation. that timeline, which its arrow keys step to; each product selects its nearest
frame at or before the world time, never a future frame or an interpolation.
The maximum gap is 30 minutes for radar, regional infrared and lightning, and The maximum gap is 30 minutes for radar, regional infrared and lightning, and
3 hours for hourly global infrared, but never less than one and a half of the 3 hours for hourly global infrared, but never less than one and a half of the
feed's median frame spacing over its latest frames, so a regional feed whose feed's median frame spacing over its latest frames, so a regional feed whose
@@ -8544,16 +8545,23 @@ lightning, 4 hours for global infrared). A missing eligible frame hides that pro
and reports the gap; rows show actual displayed UTC time with synced/nearest labels. and reports the gap; rows show actual displayed UTC time with synced/nearest labels.
Manifest rollover preserves the requested history time, even when frames expire. Manifest rollover preserves the requested history time, even when frames expire.
Latest follows each product's own newest frame, so its timestamps can differ. Live follows each product's own newest frame, so its timestamps can differ.
Playback advances two seconds after all products settle, wraps, and stops with The timeline's Play runs the world clock at the chosen speed and stops at each
fewer than two union times or when all products are suspended. Hidden documents, product's next frame until that frame loads (for at most eight seconds), so
reduced motion and unavailable imagery hosts suspend products. Re-enabling or every frame shows. Hidden documents, reduced motion and unavailable imagery
hosts suspend products. Re-enabling or
restoring a host joins the retained history target. The wind row explicitly says restoring a host joins the retained history target. The wind row explicitly says
“Forecast · does not follow history” while the observed clock is in history mode. “Forecast · does not follow history” while the observed clock is in history mode.
The catalog exposes `weatherClock` and destroys it with its lifetime signal. The Weather panel's frame time shows the newest frame and its age while live,
and the timeline time alone at a past time.
The catalog exposes `weatherClock` and destroys it with its lifetime signal. It
has no time controls of its own: `weatherClock.world` is the world clock it
follows, where time moves. A weather row's `play`, `latest` and `step` params
move that world clock as the timeline does.
Source, opacity, coverage navigation and transport controls live in the WEATHER Source, opacity and coverage navigation live in the WEATHER panel's cards, not
panel's cards and timeline, not in the rows. The viewer clock is untouched. in the rows; the panel shows the frame time and has no time controls. The
viewer clock is untouched.
Observation layers are off by default; share/persist includes appearance and product, while history Observation layers are off by default; share/persist includes appearance and product, while history
is transient and shared links open latest. Rendering retains the previous frame is transient and shared links open latest. Rendering retains the previous frame
when an incoming frame fails; no eligible historical frame instead hides imagery. when an incoming frame fails; no eligible historical frame instead hides imagery.
+3 -3
View File
@@ -153,9 +153,9 @@ a feature supplying only its accent.
`t()`, which follows the locale as the view renders; the views carry no `t()`, which follows the locale as the view renders; the views carry no
`data-i18n*` markers, which `npm run i18n` rejects in a template. Shared builders become components first and `data-i18n*` markers, which `npm run i18n` rejects in a template. Shared builders become components first and
keep their imperative entry points until their owners are components: keep their imperative entry points until their owners are components:
the rail cards, card blocks and timeline (`src/ui/rail/`) render through the rail cards and card blocks (`src/ui/rail/`) render through
Vue's synchronous `render()` behind `createRailCards()`, Vue's synchronous `render()` behind `createRailCards()` and
`createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and `createRailCardBlocks()`. Order: Scenes (done: `SceneOptions` and
`SceneShotList` over the `gev-scenes` store; the panel's buttons, status `SceneShotList` over the `gev-scenes` store; the panel's buttons, status
line, progress bar and the runtime chip render the panel SceneControls line, progress bar and the runtime chip render the panel SceneControls
presents there, and the buttons and file chooser call its actions), Weather (done: the owner presents there, and the buttons and file chooser call its actions), Weather (done: the owner
-2
View File
@@ -936,8 +936,6 @@
"src/ui/weatherPanel.test.mjs", "src/ui/weatherPanel.test.mjs",
"src/ui/railCards.js", "src/ui/railCards.js",
"src/ui/railCards.test.mjs", "src/ui/railCards.test.mjs",
"src/ui/railTimeline.js",
"src/ui/railTimeline.test.mjs",
"src/layers/weather/imageryOrder.js", "src/layers/weather/imageryOrder.js",
"src/layers/weather/imageryOrder.test.mjs", "src/layers/weather/imageryOrder.test.mjs",
"server/providers/cyclones.js", "server/providers/cyclones.js",
-6
View File
@@ -804,7 +804,6 @@
"src/ui/layerPanel/RowSwitch.vue", "src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue", "src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue", "src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/clearLayersControl.js", "src/ui/clearLayersControl.js",
"src/loadingFeedback.js", "src/loadingFeedback.js",
"src/data/installationFeedback.js", "src/data/installationFeedback.js",
@@ -837,7 +836,6 @@
"src/ui/rail/RailCard.vue", "src/ui/rail/RailCard.vue",
"src/ui/rail/RailCards.vue", "src/ui/rail/RailCards.vue",
"src/ui/rail/utcClock.js", "src/ui/rail/utcClock.js",
"src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/newsPanel.js", "src/ui/newsPanel.js",
@@ -1217,7 +1215,6 @@
"src/ui/layerPanel/RowSwitch.vue", "src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue", "src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue", "src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js", "src/ui/layers.js",
"src/ui/leftPanelRail.js", "src/ui/leftPanelRail.js",
"src/ui/location.js", "src/ui/location.js",
@@ -1312,7 +1309,6 @@
"src/ui/rail/RailCard.vue", "src/ui/rail/RailCard.vue",
"src/ui/rail/RailCards.vue", "src/ui/rail/RailCards.vue",
"src/ui/rail/utcClock.js", "src/ui/rail/utcClock.js",
"src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/newsPanel.js", "src/ui/newsPanel.js",
@@ -3168,7 +3164,6 @@
"src/ui/layerPanel/RowSwitch.vue", "src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue", "src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue", "src/ui/layerPanel/RowSlider.vue",
"src/ui/railTimeline.js",
"src/ui/layers.js", "src/ui/layers.js",
"src/ui/leftPanelRail.js", "src/ui/leftPanelRail.js",
"src/ui/location.js", "src/ui/location.js",
@@ -3724,7 +3719,6 @@
"src/ui/rail/RailCards.vue", "src/ui/rail/RailCards.vue",
"src/ui/rail/RailCardBlocks.vue", "src/ui/rail/RailCardBlocks.vue",
"src/ui/rail/utcClock.js", "src/ui/rail/utcClock.js",
"src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue", "src/ui/weather/WeatherReadout.vue",
"src/ui/newsPanel.js", "src/ui/newsPanel.js",
+19 -3
View File
@@ -6,6 +6,11 @@ import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js'; import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js'; import { LayerLifecycle } from '../data/lifecycle.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs'; import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
import { worldClock } from '../time/worldClock.js';
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
function fixtureSources(ids, calls) { function fixtureSources(ids, calls) {
const sources = createStandaloneLayerSources(); const sources = createStandaloneLayerSources();
@@ -27,6 +32,7 @@ test('catalogs construct distinct layers and classification from their supplied
t.after(() => { t.after(() => {
a.abort(); a.abort();
b.abort(); b.abort();
worldClock.goLive();
}); });
const callsA = []; const callsA = [];
const callsB = []; const callsB = [];
@@ -53,7 +59,10 @@ test('catalogs construct distinct layers and classification from their supplied
'the hardware-local layer is never serialized into links', 'the hardware-local layer is never serialized into links',
); );
assert.notEqual(first.weatherClock, second.weatherClock); assert.notEqual(first.weatherClock, second.weatherClock);
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z'); // Time moves on the world clock, which the catalog's weather clock follows.
assert.equal(first.weatherClock.world, worldClock);
worldClock.seek(Date.parse('2026-09-21T12:00:00.000Z'));
await flush();
assert.match( assert.match(
first.get('wind').getRowControls().info, first.get('wind').getRowControls().info,
/Forecast · does not follow history/, /Forecast · does not follow history/,
@@ -111,9 +120,16 @@ test('catalogs construct distinct layers and classification from their supplied
assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false); assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false);
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true); assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
a.abort(); a.abort();
worldClock.seek(Date.parse('2026-09-21T13:00:00.000Z'));
await flush();
assert.equal( assert.equal(
await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'), first.weatherClock.getState().target,
false, '2026-09-21T12:00:00.000Z',
'an aborted catalog no longer follows the world clock',
);
assert.equal(
second.weatherClock.getState().target,
'2026-09-21T13:00:00.000Z',
); );
assert.equal(callsA[0].aborted, true); assert.equal(callsA[0].aborted, true);
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false); assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false);
-5
View File
@@ -2542,12 +2542,8 @@
"PAUSE": "إيقاف مؤقت", "PAUSE": "إيقاف مؤقت",
"RESUME": "استئناف", "RESUME": "استئناف",
"Clear reading": "مسح القراءة", "Clear reading": "مسح القراءة",
"Earlier observation": "رصد أسبق",
"Observed history": "سجل الأرصاد", "Observed history": "سجل الأرصاد",
"Later observation": "رصد لاحق",
"Latest": "الأحدث", "Latest": "الأحدث",
"Newest frame per product": "أحدث إطار لكل منتج",
"Replay observed history": "إعادة تشغيل سجل الأرصاد",
"Imagery on Esri · Google 3D returns when cleared": "الصور على Esri · تعود Google 3D عند المسح", "Imagery on Esri · Google 3D returns when cleared": "الصور على Esri · تعود Google 3D عند المسح",
"Point the camera at the ground to use the view": "وجّه الكاميرا نحو الأرض لاستخدام العرض", "Point the camera at the ground to use the view": "وجّه الكاميرا نحو الأرض لاستخدام العرض",
"That point cannot anchor a box": "لا يمكن تثبيت منطقة على تلك النقطة", "That point cannot anchor a box": "لا يمكن تثبيت منطقة على تلك النقطة",
@@ -2848,7 +2844,6 @@
"dB": "dB", "dB": "dB",
"{time} · {minutes} min ago": "{time} · قبل {minutes} د", "{time} · {minutes} min ago": "{time} · قبل {minutes} د",
"Active weather": "الطقس النشط", "Active weather": "الطقس النشط",
"LATEST · newest per product": "الأحدث · آخر إطار لكل منتج",
"Forecast · valid {valid} · issued {issued}": "توقع عند {valid} · صدر {issued}", "Forecast · valid {valid} · issued {issued}": "توقع عند {valid} · صدر {issued}",
"{detail} · Does not follow history": "{detail} · لا يتبع السجل", "{detail} · Does not follow history": "{detail} · لا يتبع السجل",
"Forecast · valid {valid}": "توقع عند {valid}", "Forecast · valid {valid}": "توقع عند {valid}",
-5
View File
@@ -2402,12 +2402,8 @@
"PAUSE": "PAUSE", "PAUSE": "PAUSE",
"RESUME": "FORTSETZEN", "RESUME": "FORTSETZEN",
"Clear reading": "Wert ausblenden", "Clear reading": "Wert ausblenden",
"Earlier observation": "Frühere Beobachtung",
"Observed history": "Beobachteter Verlauf", "Observed history": "Beobachteter Verlauf",
"Later observation": "Spätere Beobachtung",
"Latest": "Neueste", "Latest": "Neueste",
"Newest frame per product": "Neuester Frame je Produkt",
"Replay observed history": "Beobachteten Verlauf abspielen",
"Imagery on Esri · Google 3D returns when cleared": "Bilder auf Esri · nach LÖSCHEN wieder Google 3D", "Imagery on Esri · Google 3D returns when cleared": "Bilder auf Esri · nach LÖSCHEN wieder Google 3D",
"Point the camera at the ground to use the view": "Kamera auf den Boden richten, um die Ansicht zu verwenden", "Point the camera at the ground to use the view": "Kamera auf den Boden richten, um die Ansicht zu verwenden",
"That point cannot anchor a box": "Dieser Punkt kann keinen Bereich verankern", "That point cannot anchor a box": "Dieser Punkt kann keinen Bereich verankern",
@@ -2656,7 +2652,6 @@
"dB": "dB", "dB": "dB",
"{time} · {minutes} min ago": "{time} · vor {minutes} min", "{time} · {minutes} min ago": "{time} · vor {minutes} min",
"Active weather": "Aktives Wetter", "Active weather": "Aktives Wetter",
"LATEST · newest per product": "NEUESTE · je Produkt",
"Forecast · valid {valid} · issued {issued}": "Vorhersage · gültig {valid} · ausgegeben {issued}", "Forecast · valid {valid} · issued {issued}": "Vorhersage · gültig {valid} · ausgegeben {issued}",
"{detail} · Does not follow history": "{detail} · Folgt nicht dem Verlauf", "{detail} · Does not follow history": "{detail} · Folgt nicht dem Verlauf",
"Forecast · valid {valid}": "Vorhersage · gültig {valid}", "Forecast · valid {valid}": "Vorhersage · gültig {valid}",
-5
View File
@@ -1667,12 +1667,8 @@
"CH {index} / {total}": "CANAL {index} / {total}", "CH {index} / {total}": "CANAL {index} / {total}",
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio", "Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
"Clear reading": "Borrar lectura", "Clear reading": "Borrar lectura",
"Earlier observation": "Observación anterior",
"Observed history": "Historial observado", "Observed history": "Historial observado",
"Later observation": "Observación posterior",
"Latest": "Último", "Latest": "Último",
"Newest frame per product": "Fotograma más reciente por producto",
"Replay observed history": "Reproducir el historial observado",
"S on another day replaces it · × unpins": "S en otro día lo reemplaza · × quita la fijación", "S on another day replaces it · × unpins": "S en otro día lo reemplaza · × quita la fijación",
"← → preview · S shows the focused day": "← → vista previa · S muestra el día enfocado", "← → preview · S shows the focused day": "← → vista previa · S muestra el día enfocado",
"Recent imagery": "Imágenes recientes", "Recent imagery": "Imágenes recientes",
@@ -1836,7 +1832,6 @@
"Reset map bearing to north": "Restablecer la orientación del mapa al norte", "Reset map bearing to north": "Restablecer la orientación del mapa al norte",
"Rain radar": "Radar de lluvia", "Rain radar": "Radar de lluvia",
"Lightning density": "Densidad de rayos", "Lightning density": "Densidad de rayos",
"LATEST · newest per product": "ÚLTIMO · más reciente por producto",
"Forecast · valid {valid} · issued {issued}": "Pronóstico · válido {valid} · emitido {issued}", "Forecast · valid {valid} · issued {issued}": "Pronóstico · válido {valid} · emitido {issued}",
"{detail} · Does not follow history": "{detail} · No sigue el historial", "{detail} · Does not follow history": "{detail} · No sigue el historial",
"Forecast · valid {valid}": "Pronóstico · válido {valid}", "Forecast · valid {valid}": "Pronóstico · válido {valid}",
-5
View File
@@ -2150,12 +2150,8 @@
"Radio is disabling…": "Désactivation de la Radio…", "Radio is disabling…": "Désactivation de la Radio…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "État de la Radio incertain — utilisez Activer ou Désactiver pour le rétablir", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "État de la Radio incertain — utilisez Activer ou Désactiver pour le rétablir",
"Clear reading": "Effacer le relevé", "Clear reading": "Effacer le relevé",
"Earlier observation": "Observation précédente",
"Observed history": "Historique des observations", "Observed history": "Historique des observations",
"Later observation": "Observation suivante",
"Latest": "Dernière", "Latest": "Dernière",
"Newest frame per product": "Image la plus récente par produit",
"Replay observed history": "Rejouer l'historique des observations",
"Imagery on Esri · Google 3D returns when cleared": "Sur Esri · EFFACER rétablit Google 3D", "Imagery on Esri · Google 3D returns when cleared": "Sur Esri · EFFACER rétablit Google 3D",
"Point the camera at the ground to use the view": "Orientez la caméra vers le sol pour utiliser la vue", "Point the camera at the ground to use the view": "Orientez la caméra vers le sol pour utiliser la vue",
"That point cannot anchor a box": "Ce point ne peut pas ancrer une zone", "That point cannot anchor a box": "Ce point ne peut pas ancrer une zone",
@@ -2446,7 +2442,6 @@
"DETECT": "DÉTECT.", "DETECT": "DÉTECT.",
"{time} · {minutes} min ago": "{time} · il y a {minutes} min", "{time} · {minutes} min ago": "{time} · il y a {minutes} min",
"Active weather": "Couches météo actives", "Active weather": "Couches météo actives",
"LATEST · newest per product": "DERNIÈRE · la plus récente par produit",
"Forecast · valid {valid} · issued {issued}": "Prévision · valide {valid} · émise {issued}", "Forecast · valid {valid} · issued {issued}": "Prévision · valide {valid} · émise {issued}",
"{detail} · Does not follow history": "{detail} · Ne suit pas l'historique", "{detail} · Does not follow history": "{detail} · Ne suit pas l'historique",
"Forecast · valid {valid}": "Prévision · valide {valid}", "Forecast · valid {valid}": "Prévision · valide {valid}",
-5
View File
@@ -1996,11 +1996,7 @@
"READY": "מוכן", "READY": "מוכן",
"Clear reading": "ניקוי הקריאה", "Clear reading": "ניקוי הקריאה",
"Observed history": "היסטוריה נצפית", "Observed history": "היסטוריה נצפית",
"Earlier observation": "תצפית קודמת",
"Later observation": "תצפית מאוחרת",
"Newest frame per product": "השקופית החדשה לכל מוצר",
"Latest": "האחרונה", "Latest": "האחרונה",
"Replay observed history": "הצגת ההיסטוריה הנצפית שוב",
"Imagery on Esri · Google 3D returns when cleared": "צילומים ב-Esri · Google 3D חוזר כשמנקים", "Imagery on Esri · Google 3D returns when cleared": "צילומים ב-Esri · Google 3D חוזר כשמנקים",
"Point the camera at the ground to use the view": "כוון את המצלמה אל הקרקע כדי להשתמש בתצוגה", "Point the camera at the ground to use the view": "כוון את המצלמה אל הקרקע כדי להשתמש בתצוגה",
"That point cannot anchor a box": "אין אפשרות לעגן תיבה באותה נקודה", "That point cannot anchor a box": "אין אפשרות לעגן תיבה באותה נקודה",
@@ -2783,7 +2779,6 @@
"Detection overlay: off": "שכבת הזיהוי: כבויה", "Detection overlay: off": "שכבת הזיהוי: כבויה",
"Active weather": "מזג אוויר פעיל", "Active weather": "מזג אוויר פעיל",
"{time} · {minutes} min ago": "{time} · לפני {minutes} דק׳", "{time} · {minutes} min ago": "{time} · לפני {minutes} דק׳",
"LATEST · newest per product": "האחרון · החדש ביותר לכל מוצר",
"Forecast · valid {valid} · issued {issued}": "תחזית · תקפה {valid} · פורסמה {issued}", "Forecast · valid {valid} · issued {issued}": "תחזית · תקפה {valid} · פורסמה {issued}",
"{detail} · Does not follow history": "{detail} · אינו נע עם ההיסטוריה", "{detail} · Does not follow history": "{detail} · אינו נע עם ההיסטוריה",
"Forecast · valid {valid}": "תחזית · תקפה {valid}", "Forecast · valid {valid}": "תחזית · תקפה {valid}",
-5
View File
@@ -2150,12 +2150,8 @@
"Radio is disabling…": "Disattivazione della Radio…", "Radio is disabling…": "Disattivazione della Radio…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Il ciclo di vita della Radio è incerto: usa Attiva o Disattiva per riconciliare", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Il ciclo di vita della Radio è incerto: usa Attiva o Disattiva per riconciliare",
"Clear reading": "Cancella la lettura", "Clear reading": "Cancella la lettura",
"Earlier observation": "Osservazione precedente",
"Observed history": "Storico osservato", "Observed history": "Storico osservato",
"Later observation": "Osservazione successiva",
"Latest": "Più recente", "Latest": "Più recente",
"Newest frame per product": "Fotogramma più recente per prodotto",
"Replay observed history": "Rivedi lo storico osservato",
"Imagery on Esri · Google 3D returns when cleared": "Immagini su Esri · Google 3D torna con CANCELLA", "Imagery on Esri · Google 3D returns when cleared": "Immagini su Esri · Google 3D torna con CANCELLA",
"Point the camera at the ground to use the view": "Punta la camera verso il suolo per usare la vista", "Point the camera at the ground to use the view": "Punta la camera verso il suolo per usare la vista",
"That point cannot anchor a box": "Quel punto non può ancorare un riquadro", "That point cannot anchor a box": "Quel punto non può ancorare un riquadro",
@@ -2446,7 +2442,6 @@
"DETECT": "RILEV.", "DETECT": "RILEV.",
"{time} · {minutes} min ago": "{time} · {minutes} min fa", "{time} · {minutes} min ago": "{time} · {minutes} min fa",
"Active weather": "Meteo attivo", "Active weather": "Meteo attivo",
"LATEST · newest per product": "ULTIMO · il più recente per prodotto",
"Forecast · valid {valid} · issued {issued}": "Previsione · valida {valid} · emessa {issued}", "Forecast · valid {valid} · issued {issued}": "Previsione · valida {valid} · emessa {issued}",
"{detail} · Does not follow history": "{detail} · Non segue lo storico", "{detail} · Does not follow history": "{detail} · Non segue lo storico",
"Forecast · valid {valid}": "Previsione · valida {valid}", "Forecast · valid {valid}": "Previsione · valida {valid}",
-5
View File
@@ -2076,12 +2076,8 @@
"Radio is disabling…": "ラジオを無効化しています…", "Radio is disabling…": "ラジオを無効化しています…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "ラジオのライフサイクルが不明確です。有効化または無効化で再調整してください", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "ラジオのライフサイクルが不明確です。有効化または無効化で再調整してください",
"Clear reading": "読み取り値を消去", "Clear reading": "読み取り値を消去",
"Earlier observation": "前の観測",
"Observed history": "観測履歴", "Observed history": "観測履歴",
"Later observation": "次の観測",
"Latest": "最新", "Latest": "最新",
"Newest frame per product": "プロダクトごとの最新フレーム",
"Replay observed history": "観測履歴を再生",
"Imagery on Esri · Google 3D returns when cleared": "画像はEsri上に表示 · 消去するとGoogle 3Dに戻ります", "Imagery on Esri · Google 3D returns when cleared": "画像はEsri上に表示 · 消去するとGoogle 3Dに戻ります",
"Point the camera at the ground to use the view": "このビューを使うにはカメラを地面に向けてください", "Point the camera at the ground to use the view": "このビューを使うにはカメラを地面に向けてください",
"That point cannot anchor a box": "その地点は範囲の基点にできません", "That point cannot anchor a box": "その地点は範囲の基点にできません",
@@ -2346,7 +2342,6 @@
"DETECT": "検出", "DETECT": "検出",
"{time} · {minutes} min ago": "{time} · {minutes}分前", "{time} · {minutes} min ago": "{time} · {minutes}分前",
"Active weather": "有効な気象レイヤー", "Active weather": "有効な気象レイヤー",
"LATEST · newest per product": "最新 · プロダクトごとの最新",
"Forecast · valid {valid} · issued {issued}": "予報 · 対象時刻 {valid} · 発表 {issued}", "Forecast · valid {valid} · issued {issued}": "予報 · 対象時刻 {valid} · 発表 {issued}",
"{detail} · Does not follow history": "{detail} · 履歴とは連動しません", "{detail} · Does not follow history": "{detail} · 履歴とは連動しません",
"Forecast · valid {valid}": "予報 · 対象時刻 {valid}", "Forecast · valid {valid}": "予報 · 対象時刻 {valid}",
-5
View File
@@ -2150,12 +2150,8 @@
"Radio is disabling…": "O rádio está sendo desativado…", "Radio is disabling…": "O rádio está sendo desativado…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "O ciclo de vida do rádio é incerto — use Ativar ou Desativar para reconciliar", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "O ciclo de vida do rádio é incerto — use Ativar ou Desativar para reconciliar",
"Clear reading": "Limpar leitura", "Clear reading": "Limpar leitura",
"Earlier observation": "Observação anterior",
"Observed history": "Histórico observado", "Observed history": "Histórico observado",
"Later observation": "Observação posterior",
"Latest": "Mais recente", "Latest": "Mais recente",
"Newest frame per product": "Quadro mais recente por produto",
"Replay observed history": "Reproduzir o histórico observado",
"Imagery on Esri · Google 3D returns when cleared": "Imagens sobre o Esri · LIMPAR restaura o Google 3D", "Imagery on Esri · Google 3D returns when cleared": "Imagens sobre o Esri · LIMPAR restaura o Google 3D",
"Point the camera at the ground to use the view": "Aponte a câmera para o chão para usar a vista", "Point the camera at the ground to use the view": "Aponte a câmera para o chão para usar a vista",
"That point cannot anchor a box": "Esse ponto não pode ancorar uma área", "That point cannot anchor a box": "Esse ponto não pode ancorar uma área",
@@ -2446,7 +2442,6 @@
"DETECT": "DETECÇÃO", "DETECT": "DETECÇÃO",
"{time} · {minutes} min ago": "{time} · há {minutes} min", "{time} · {minutes} min ago": "{time} · há {minutes} min",
"Active weather": "Camadas de clima ativas", "Active weather": "Camadas de clima ativas",
"LATEST · newest per product": "MAIS RECENTE · último por produto",
"Forecast · valid {valid} · issued {issued}": "Previsão · válida para {valid} · emitida em {issued}", "Forecast · valid {valid} · issued {issued}": "Previsão · válida para {valid} · emitida em {issued}",
"{detail} · Does not follow history": "{detail} · Não acompanha o histórico", "{detail} · Does not follow history": "{detail} · Não acompanha o histórico",
"Forecast · valid {valid}": "Previsão · válida para {valid}", "Forecast · valid {valid}": "Previsão · válida para {valid}",
-5
View File
@@ -1697,12 +1697,8 @@
"CH {index} / {total}": "КАН. {index} / {total}", "CH {index} / {total}": "КАН. {index} / {total}",
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога", "Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
"Clear reading": "Очистить показания", "Clear reading": "Очистить показания",
"Earlier observation": "Более раннее наблюдение",
"Observed history": "История наблюдений", "Observed history": "История наблюдений",
"Later observation": "Более позднее наблюдение",
"Latest": "Последние", "Latest": "Последние",
"Newest frame per product": "Самый свежий кадр каждого продукта",
"Replay observed history": "Воспроизвести историю наблюдений",
"S on another day replaces it · × unpins": "S в другой день заменяет его · × открепляет", "S on another day replaces it · × unpins": "S в другой день заменяет его · × открепляет",
"← → preview · S shows the focused day": "← → просмотр · S показывает выбранный день", "← → preview · S shows the focused day": "← → просмотр · S показывает выбранный день",
"Recent imagery": "Свежие снимки", "Recent imagery": "Свежие снимки",
@@ -1867,7 +1863,6 @@
"Reset map bearing to north": "Сбросить азимут карты на север", "Reset map bearing to north": "Сбросить азимут карты на север",
"Rain radar": "Радар осадков", "Rain radar": "Радар осадков",
"Lightning density": "Плотность молний", "Lightning density": "Плотность молний",
"LATEST · newest per product": "ПОСЛЕДНИЕ · самые свежие по каждому продукту",
"Forecast · valid {valid} · issued {issued}": "Прогноз · действует на {valid} · выпущен {issued}", "Forecast · valid {valid} · issued {issued}": "Прогноз · действует на {valid} · выпущен {issued}",
"{detail} · Does not follow history": "{detail} · не следует за историей", "{detail} · Does not follow history": "{detail} · не следует за историей",
"Forecast · valid {valid}": "Прогноз · действует на {valid}", "Forecast · valid {valid}": "Прогноз · действует на {valid}",
-5
View File
@@ -2324,12 +2324,8 @@
"PAUSE": "PAUSA", "PAUSE": "PAUSA",
"RESUME": "FORTSÄTT", "RESUME": "FORTSÄTT",
"Clear reading": "Rensa avläsningen", "Clear reading": "Rensa avläsningen",
"Earlier observation": "Tidigare observation",
"Observed history": "Observerad historik", "Observed history": "Observerad historik",
"Later observation": "Senare observation",
"Latest": "Senaste", "Latest": "Senaste",
"Newest frame per product": "Senaste bildruta per produkt",
"Replay observed history": "Spela upp observerad historik",
"Imagery on Esri · Google 3D returns when cleared": "Bilder på Esri · Google 3D återkommer när de rensas", "Imagery on Esri · Google 3D returns when cleared": "Bilder på Esri · Google 3D återkommer när de rensas",
"Point the camera at the ground to use the view": "Rikta kameran mot marken för att använda vyn", "Point the camera at the ground to use the view": "Rikta kameran mot marken för att använda vyn",
"That point cannot anchor a box": "Den punkten kan inte förankra ett område", "That point cannot anchor a box": "Den punkten kan inte förankra ett område",
@@ -2578,7 +2574,6 @@
"dB": "dB", "dB": "dB",
"{time} · {minutes} min ago": "{time} · för {minutes} min sedan", "{time} · {minutes} min ago": "{time} · för {minutes} min sedan",
"Active weather": "Aktuellt väder", "Active weather": "Aktuellt väder",
"LATEST · newest per product": "SENASTE · nyaste per produkt",
"Forecast · valid {valid} · issued {issued}": "Prognos · gäller {valid} · utfärdad {issued}", "Forecast · valid {valid} · issued {issued}": "Prognos · gäller {valid} · utfärdad {issued}",
"{detail} · Does not follow history": "{detail} · Följer inte historiken", "{detail} · Does not follow history": "{detail} · Följer inte historiken",
"Forecast · valid {valid}": "Prognos · gäller {valid}", "Forecast · valid {valid}": "Prognos · gäller {valid}",
-5
View File
@@ -2186,12 +2186,8 @@
"Radio is disabling…": "Радіо вимикається…", "Radio is disabling…": "Радіо вимикається…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Стан радіо невизначений — звірте через «Увімкнути» або «Вимкнути»", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Стан радіо невизначений — звірте через «Увімкнути» або «Вимкнути»",
"Clear reading": "Очистити покази", "Clear reading": "Очистити покази",
"Earlier observation": "Попереднє спостереження",
"Observed history": "Історія спостережень", "Observed history": "Історія спостережень",
"Later observation": "Наступне спостереження",
"Latest": "Останні", "Latest": "Останні",
"Newest frame per product": "Найсвіжіший кадр кожного продукту",
"Replay observed history": "Відтворити історію спостережень",
"Imagery on Esri · Google 3D returns when cleared": "Підкладка: Esri · скидання поверне Google 3D", "Imagery on Esri · Google 3D returns when cleared": "Підкладка: Esri · скидання поверне Google 3D",
"Point the camera at the ground to use the view": "Наведіть камеру на землю, щоб вибрати видиму область", "Point the camera at the ground to use the view": "Наведіть камеру на землю, щоб вибрати видиму область",
"That point cannot anchor a box": "Від цієї точки не можна побудувати область", "That point cannot anchor a box": "Від цієї точки не можна побудувати область",
@@ -2495,7 +2491,6 @@
"DETECT": "ВИЯВЛЕННЯ", "DETECT": "ВИЯВЛЕННЯ",
"{time} · {minutes} min ago": "{time} · {minutes} хв тому", "{time} · {minutes} min ago": "{time} · {minutes} хв тому",
"Active weather": "Активні погодні шари", "Active weather": "Активні погодні шари",
"LATEST · newest per product": "ОСТАННІ · найсвіжіші для кожного продукту",
"Forecast · valid {valid} · issued {issued}": "Прогноз · чинний на {valid} · випущено {issued}", "Forecast · valid {valid} · issued {issued}": "Прогноз · чинний на {valid} · випущено {issued}",
"{detail} · Does not follow history": "{detail} · не синхронізується з історією", "{detail} · Does not follow history": "{detail} · не синхронізується з історією",
"Forecast · valid {valid}": "Прогноз · чинний на {valid}", "Forecast · valid {valid}": "Прогноз · чинний на {valid}",
-5
View File
@@ -2046,12 +2046,8 @@
"Radio is disabling…": "电台正在停用…", "Radio is disabling…": "电台正在停用…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对",
"Clear reading": "清除读数", "Clear reading": "清除读数",
"Earlier observation": "较早观测",
"Observed history": "观测历史", "Observed history": "观测历史",
"Later observation": "较晚观测",
"Latest": "最新", "Latest": "最新",
"Newest frame per product": "每个产品的最新一帧",
"Replay observed history": "回放观测历史",
"Imagery on Esri · Google 3D returns when cleared": "影像显示在 Esri 底图上 · 清除后恢复 Google 3D", "Imagery on Esri · Google 3D returns when cleared": "影像显示在 Esri 底图上 · 清除后恢复 Google 3D",
"Point the camera at the ground to use the view": "将相机对准地面才能使用当前视野", "Point the camera at the ground to use the view": "将相机对准地面才能使用当前视野",
"That point cannot anchor a box": "该点无法作为框选锚点", "That point cannot anchor a box": "该点无法作为框选锚点",
@@ -2310,7 +2306,6 @@
"DETECT": "探测", "DETECT": "探测",
"{time} · {minutes} min ago": "{time} · {minutes} 分钟前", "{time} · {minutes} min ago": "{time} · {minutes} 分钟前",
"Active weather": "已启用的天气图层", "Active weather": "已启用的天气图层",
"LATEST · newest per product": "最新 · 每个产品的最新一帧",
"Forecast · valid {valid} · issued {issued}": "预报 · 有效时间 {valid} · 发布时间 {issued}", "Forecast · valid {valid} · issued {issued}": "预报 · 有效时间 {valid} · 发布时间 {issued}",
"{detail} · Does not follow history": "{detail} · 不随观测历史变化", "{detail} · Does not follow history": "{detail} · 不随观测历史变化",
"Forecast · valid {valid}": "预报 · 有效时间 {valid}", "Forecast · valid {valid}": "预报 · 有效时间 {valid}",
-5
View File
@@ -2036,12 +2036,8 @@
"Radio is disabling…": "電臺正在停用…", "Radio is disabling…": "電臺正在停用…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對", "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對",
"Clear reading": "清除讀數", "Clear reading": "清除讀數",
"Earlier observation": "較早觀測",
"Observed history": "觀測歷史", "Observed history": "觀測歷史",
"Later observation": "較晚觀測",
"Latest": "最新", "Latest": "最新",
"Newest frame per product": "每個產品的最新一幀",
"Replay observed history": "回放觀測歷史",
"Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D", "Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D",
"Point the camera at the ground to use the view": "將相機對準地面才能使用當前視野", "Point the camera at the ground to use the view": "將相機對準地面才能使用當前視野",
"That point cannot anchor a box": "該點無法作為框選錨點", "That point cannot anchor a box": "該點無法作為框選錨點",
@@ -2300,7 +2296,6 @@
"DETECT": "探測", "DETECT": "探測",
"{time} · {minutes} min ago": "{time} · {minutes} 分鐘前", "{time} · {minutes} min ago": "{time} · {minutes} 分鐘前",
"Active weather": "已啟用的天氣圖層", "Active weather": "已啟用的天氣圖層",
"LATEST · newest per product": "最新 · 每個產品的最新一幀",
"Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}", "Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}",
"{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化", "{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化",
"Forecast · valid {valid}": "預報 · 有效時間 {valid}", "Forecast · valid {valid}": "預報 · 有效時間 {valid}",
+22 -146
View File
@@ -59,12 +59,15 @@ export const PLAY_WAIT_MS = 8_000;
// costs play nothing. // costs play nothing.
let clocks = 0; let clocks = 0;
// How many weather clocks follow each world clock.
const followers = new WeakMap();
/** /**
* Transient observed time. Products must honor the signal before installing a * Transient observed time. Products must honor the signal before installing a
* frame. Its time is the world clock's: latest while the world is live, * frame. Its time is the world clock's: latest while the world is live,
* history at the world's time otherwise. Replay steps the observed frames and * history at the world's time otherwise. It never moves the world clock
* moves the world clock as it goes, as the world clock's driver. * itself; the world's own controls (its timeline) move time, and while the
* world plays it stops at each next frame until that frame loads.
*/ */
export function createWeatherClock({ export function createWeatherClock({
now = Date.now, now = Date.now,
@@ -76,17 +79,10 @@ export function createWeatherClock({
const listeners = new Set(); const listeners = new Set();
let mode = 'latest'; let mode = 'latest';
let target = null; let target = null;
let playing = false; // The world time last applied.
// The world time last applied; while this clock moves the world, the
// transition its move started.
let seen = 'latest'; let seen = 'latest';
let moving = false;
let moved = null;
const releases = new Map(); const releases = new Map();
// Attached at the end, once the API exists.
let driver = null;
let generation = 0; let generation = 0;
let timer = null;
let pending = null; let pending = null;
// This clock's name on the world, for its holds and its gate. // This clock's name on the world, for its holds and its gate.
const holdId = `weather:${++clocks}`; const holdId = `weather:${++clocks}`;
@@ -147,10 +143,9 @@ export function createWeatherClock({
const getState = () => ({ const getState = () => ({
mode, mode,
target, target,
playing, // Frames come on one after another as the world plays on through them,
// Frames come on one after another: this clock replays them, or the world // so the next is worth loading ahead.
// plays on through them, so the next is worth loading ahead. advancing: worldPlaysOn(),
advancing: playing || worldPlaysOn(),
timeline: getTimeline(), timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({ products: [...registry.values()].map(({ product }) => ({
id: product.id, id: product.id,
@@ -162,60 +157,12 @@ export function createWeatherClock({
const notify = () => { const notify = () => {
if (!destroyed) for (const listener of listeners) listener(getState()); if (!destroyed) for (const listener of listeners) listener(getState());
}; };
const cancelTimer = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
const canPlay = () => getTimeline().length >= 2;
const stopPlaying = () => {
if (!playing) return;
playing = false;
driver?.driven(false);
};
// Move the world clock; the change comes back through followWorld. Returns
// the transition the move started, if it started one.
const move = (fn) => {
const outer = [moving, moved];
moving = true;
moved = null;
try {
fn();
return moved;
} finally {
[moving, moved] = outer;
}
};
const selections = () => const selections = () =>
[...registry.values()].map(({ product }) => selectedFor(product)).join(); [...registry.values()].map(({ product }) => selectedFor(product)).join();
let applied = null; let applied = null;
function schedule() {
cancelTimer();
if (!playing || destroyed || pending) return;
if (!canPlay()) {
stopPlaying();
notify();
return;
}
const owner = generation;
timer = setTimeout(() => {
timer = null;
if (destroyed || !playing || owner !== generation) return;
const timeline = getTimeline();
if (timeline.length < 2) {
api.pause();
return;
}
const next = timeline.find(
(time) => Date.parse(time) > Date.parse(target),
);
void api.setTarget(next ?? timeline[0]);
}, 2000);
}
function transition() { function transition() {
if (destroyed) return Promise.resolve(false); if (destroyed) return Promise.resolve(false);
cancelTimer();
const owner = ++generation; const owner = ++generation;
if (!canPlay()) stopPlaying();
// A scrub drops what loads and moves on; play goes on as this loads. // A scrub drops what loads and moves on; play goes on as this loads.
applied = selections(); applied = selections();
deferred = false; deferred = false;
@@ -235,15 +182,12 @@ export function createWeatherClock({
if (destroyed || owner !== generation) return false; if (destroyed || owner !== generation) return false;
stopWaiting(); stopWaiting();
pending = null; pending = null;
if (!canPlay()) stopPlaying();
notify(); notify();
schedule();
// Play held at the next frame for this one goes on to it. // Play held at the next frame for this one goes on to it.
if (deferred) void transition(); if (deferred) void transition();
return true; return true;
}); });
pending = result; pending = result;
if (moving) moved ??= result;
notify(); notify();
return result; return result;
} }
@@ -294,81 +238,17 @@ export function createWeatherClock({
}, },
getTimeline, getTimeline,
selectFor, selectFor,
setTarget(time) { /**
if (destroyed) return Promise.resolve(false); * The world clock this clock follows. Time moves there, through the same
if (!Number.isFinite(Date.parse(time))) * seek, play and live the world's timeline uses, never through this clock.
throw new TypeError('Weather target must be a UTC time'); */
const normalized = new Date(time).toISOString(); world,
if (mode === 'history' && target === normalized)
return pending ?? Promise.resolve(true);
return (
move(() => world.seek(Date.parse(normalized))) ??
pending ??
Promise.resolve(true)
);
},
step(direction) {
if (destroyed || ![-1, 1].includes(direction))
return Promise.resolve(false);
api.pause();
const timeline = getTimeline();
if (!timeline.length) return Promise.resolve(false);
const at = Date.parse(target ?? timeline.at(-1));
const next =
direction < 0
? (timeline.findLast((time) => Date.parse(time) < at) ?? timeline[0])
: (timeline.find((time) => Date.parse(time) > at) ?? timeline.at(-1));
return api.setTarget(next);
},
latest() {
if (destroyed) return Promise.resolve(false);
stopPlaying();
if (mode === 'latest') return transition();
return move(() => world.goLive()) ?? pending ?? Promise.resolve(true);
},
play() {
if (destroyed || playing || !canPlay())
return pending ?? Promise.resolve(false);
playing = true;
if (mode === 'latest') {
const timeline = getTimeline();
const start =
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0];
// Playing and at the first frame in one move of the world.
return (
move(() => driver.driven(true, Date.parse(start))) ??
pending ??
Promise.resolve(true)
);
}
driver.driven(true);
notify();
schedule();
return pending ?? Promise.resolve(true);
},
pause() {
cancelTimer();
if (!playing) {
// The world may be playing on its own; a pause or step here holds it.
if (world.isPlaying()) world.pause();
return;
}
stopPlaying();
notify();
},
togglePlay() {
return playing ? api.pause() : api.play();
},
refresh() { refresh() {
if (destroyed) return Promise.resolve(false); if (destroyed) return Promise.resolve(false);
// New frames, or products added or dropped, and with them the next. // New frames, or products added or dropped, and with them the next.
world.coverageChanged(); world.coverageChanged();
gated = undefined; gated = undefined;
gateNext(world.getState()); gateNext(world.getState());
if (!canPlay()) {
stopPlaying();
cancelTimer();
}
if (mode === 'history') return transition(); if (mode === 'history') return transition();
notify(); notify();
return Promise.resolve(true); return Promise.resolve(true);
@@ -380,15 +260,15 @@ export function createWeatherClock({
}, },
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
stopPlaying();
destroyed = true; destroyed = true;
++generation; ++generation;
stopWaiting(); stopWaiting();
world.gate(holdId, null); world.gate(holdId, null);
cancelTimer();
unfollowWorld(); unfollowWorld();
// The last weather clock leaves the world live, as a new one expects. // The last weather clock leaves the world live, as a new one expects.
if (driver.detach() === 0) world.goLive(); const left = followers.get(world) - 1;
followers.set(world, left);
if (left === 0) world.goLive();
for (const release of releases.values()) release(); for (const release of releases.values()) release();
releases.clear(); releases.clear();
for (const entry of registry.values()) entry.controller?.abort(); for (const entry of registry.values()) entry.controller?.abort();
@@ -397,9 +277,8 @@ export function createWeatherClock({
pending = null; pending = null;
}, },
}; };
// Follow the world clock. A move of this clock's own always applies; one // Follow the world clock. A move (a seek, play going on) applies only when
// from elsewhere (a host's seek, continuous play) applies only when it // it changes the mode or a product's frame.
// changes the mode or a product's frame.
function followWorld(state) { function followWorld(state) {
if (destroyed) return; if (destroyed) return;
gateNext(state); gateNext(state);
@@ -420,7 +299,7 @@ export function createWeatherClock({
const modeChanged = nextMode !== mode; const modeChanged = nextMode !== mode;
mode = nextMode; mode = nextMode;
target = nextTarget; target = nextTarget;
if (!moving && !modeChanged && selections() === applied) { if (!modeChanged && selections() === applied) {
// Back at the frame that loads, play need not hold for it. // Back at the frame that loads, play need not hold for it.
if (deferred) { if (deferred) {
deferred = false; deferred = false;
@@ -433,7 +312,7 @@ export function createWeatherClock({
} }
// Playing on into the next frame while the one before it loads, the // Playing on into the next frame while the one before it loads, the
// world holds here until that one shows. // world holds here until that one shows.
if (!moving && !modeChanged && pending && playsOn) { if (!modeChanged && pending && playsOn) {
deferred = true; deferred = true;
startWaiting(); startWaiting();
notify(); notify();
@@ -456,10 +335,7 @@ export function createWeatherClock({
world.gate(holdId, next); world.gate(holdId, next);
} }
const unfollowWorld = world.subscribe(followWorld); const unfollowWorld = world.subscribe(followWorld);
driver = world.attachDriver({ followers.set(world, (followers.get(world) ?? 0) + 1);
play: () => (canPlay() ? void api.play() : false),
pause: () => api.pause(),
});
followWorld(world.getState()); followWorld(world.getState());
return api; return api;
} }
+59 -193
View File
@@ -158,13 +158,16 @@ test('a product’s gap is at least one and a half of its feed’s frame spacing
assert.equal(clock.selectFor('clouds', time(211)), null); assert.equal(clock.selectFor('clouds', time(211)), null);
}); });
test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => { const at = (minutes) => Date.parse(time(minutes));
const { clock } = harness(t);
test('history applies per-product selections, including null; live restores unequal newest times', async (t) => {
const { clock, world } = harness(t);
const radar = product('radar', [0, 10, 20]); const radar = product('radar', [0, 10, 20]);
const satellite = product('satellite', [5, 15]); const satellite = product('satellite', [5, 15]);
const lightning = product('lightning', [15]); const lightning = product('lightning', [15]);
for (const p of [radar, satellite, lightning]) clock.register(p); for (const p of [radar, satellite, lightning]) clock.register(p);
await clock.setTarget(time(10)); world.seek(at(10));
await flush();
assert.deepEqual( assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown], [radar.shown, satellite.shown, lightning.shown],
[time(10), time(5), null], [time(10), time(5), null],
@@ -173,18 +176,19 @@ test('history applies per-product selections, including null; latest restores un
clock.getState().products.map(({ selected }) => selected), clock.getState().products.map(({ selected }) => selected),
[time(10), time(5), null], [time(10), time(5), null],
); );
await clock.latest(); world.goLive();
await flush();
assert.deepEqual( assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown], [radar.shown, satellite.shown, lightning.shown],
[time(20), time(15), time(15)], [time(20), time(15), time(15)],
); );
assert.equal(clock.getState().mode, 'latest'); assert.equal(clock.getState().mode, 'latest');
assert.equal(clock.getState().target, null); assert.equal(clock.getState().target, null);
assert.equal(clock.getState().playing, false); assert.equal(clock.getState().advancing, false);
}); });
test('superseded completions cannot commit, notify or schedule a new playback advance', async (t) => { test('superseded completions cannot commit or notify', async (t) => {
const { clock, timers } = harness(t); const { clock, world, timers } = harness(t);
const p = product('radar', [0, 10, 20]); const p = product('radar', [0, 10, 20]);
const loads = []; const loads = [];
p.apply = (selected, { signal }) => p.apply = (selected, { signal }) =>
@@ -201,113 +205,44 @@ test('superseded completions cannot commit, notify or schedule a new playback ad
clock.register(p); clock.register(p);
let notifications = 0; let notifications = 0;
clock.subscribe(() => notifications++); clock.subscribe(() => notifications++);
const old = clock.setTarget(time(0)); world.seek(at(0));
await flush(); await flush();
const current = clock.setTarget(time(10)); world.seek(at(10));
await flush(); await flush();
assert.equal(loads[0].signal.aborted, true); assert.equal(loads[0].signal.aborted, true);
loads[1].finish(); loads[1].finish();
assert.equal(await current, true); await flush();
const count = notifications; const count = notifications;
loads[0].finish(); loads[0].finish();
assert.equal(await old, false); await flush();
assert.equal(notifications, count); assert.equal(notifications, count);
assert.equal(p.shown, time(10)); assert.equal(p.shown, time(10));
assert.equal(clock.getState().target, time(10)); assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0); assert.equal(timers.size, 0);
}); });
test('steps use strict union neighbours from arbitrary targets and clamp at endpoints', async (t) => { test('the clock has no time controls of its own; the world clock moves time', (t) => {
const { clock } = harness(t); const { clock, world } = harness(t);
clock.register(product('radar', [0, 10, 20])); for (const name of [
clock.register(product('satellite', [5, 15])); 'setTarget',
await clock.step(-1); 'step',
assert.equal(clock.getState().target, time(15)); 'latest',
await clock.setTarget(time(12)); 'play',
await clock.step(-1); 'pause',
assert.equal(clock.getState().target, time(10)); 'togglePlay',
await clock.setTarget(time(12)); ])
await clock.step(1); assert.equal(clock[name], undefined, name);
assert.equal(clock.getState().target, time(15)); assert.equal(clock.world, world);
await clock.setTarget(time(0));
await clock.step(-1);
assert.equal(clock.getState().target, time(0));
await clock.setTarget(time(20));
await clock.step(1);
assert.equal(clock.getState().target, time(20));
});
test('playback waits for every product to settle, including rejection, then waits 2000 ms and wraps', async (t) => {
const { clock, tick, timers } = harness(t);
const a = product('radar', [0, 10]);
const b = product('satellite', [5]);
const loads = [];
b.apply = () =>
new Promise((resolve, reject) => loads.push({ resolve, reject }));
clock.register(a);
clock.register(b);
const start = clock.play();
await flush();
assert.equal(clock.getState().target, time(10));
tick(10_000);
assert.equal(timers.size, 0);
assert.equal(a.calls.length, 1);
loads[0].reject(new Error('unavailable'));
await start;
assert.equal(timers.size, 1);
tick(1999);
assert.equal(clock.getState().target, time(10));
tick(1);
await flush();
assert.equal(clock.getState().target, time(0));
assert.equal(timers.size, 0);
tick(10_000);
assert.equal(clock.getState().target, time(0));
loads[1].resolve(true);
await flush();
tick(2000);
await flush();
assert.equal(clock.getState().target, time(5));
clock.pause();
loads[2].resolve(false);
await flush();
assert.equal(timers.size, 0, 'pausing during a load prevents rescheduling');
});
test('pause cancels a pending advance; playback stops with one time or all products suspended', async (t) => {
const { clock, tick, timers } = harness(t);
const p = product('radar', [0, 10]);
clock.register(p);
await clock.play();
await clock.play();
assert.equal(timers.size, 1);
clock.pause();
tick(2000);
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
await clock.togglePlay();
p.times = [time(0)];
await clock.refresh();
assert.equal(clock.getState().playing, false);
await clock.play();
assert.equal(clock.getState().playing, false);
p.times = [time(0), time(10)];
await clock.refresh();
await clock.play();
p.suspended = true;
await clock.refresh();
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []);
assert.equal(timers.size, 0);
}); });
test('refresh does not apply in latest; history retains its target as expired frames disappear', async (t) => { test('refresh does not apply in latest; history retains its target as expired frames disappear', async (t) => {
const { clock } = harness(t); const { clock, world } = harness(t);
const p = product('radar', [0, 10]); const p = product('radar', [0, 10]);
clock.register(p); clock.register(p);
await clock.refresh(); await clock.refresh();
assert.equal(p.calls.length, 0); assert.equal(p.calls.length, 0);
await clock.setTarget(time(10)); world.seek(at(10));
await flush();
p.times = [time(0), time(20)]; p.times = [time(0), time(20)];
await clock.refresh(); await clock.refresh();
assert.equal(p.shown, time(0)); assert.equal(p.shown, time(0));
@@ -318,15 +253,16 @@ test('refresh does not apply in latest; history retains its target as expired fr
}); });
test('registration joins history; unregister and destroy abort work and release subscriptions/timers', async (t) => { test('registration joins history; unregister and destroy abort work and release subscriptions/timers', async (t) => {
const { clock, timers, tick } = harness(t); const { clock, world, timers } = harness(t);
const p = product('radar', [0, 10]); const p = product('radar', [0, 10]);
const unregister = clock.register(p); const unregister = clock.register(p);
await clock.setTarget(time(0)); world.seek(at(0));
await flush();
const b = product('satellite', [0, 5]); const b = product('satellite', [0, 5]);
clock.register(b); clock.register(b);
await flush(); await flush();
assert.equal(b.shown, time(0)); assert.equal(b.shown, time(0));
await clock.play(); world.seek(at(5));
unregister(); unregister();
unregister(); unregister();
await flush(); await flush();
@@ -337,31 +273,27 @@ test('registration joins history; unregister and destroy abort work and release
); );
let notifications = 0; let notifications = 0;
const off = clock.subscribe(() => notifications++); const off = clock.subscribe(() => notifications++);
clock.pause();
off(); off();
const before = notifications; world.seek(at(0));
await clock.play(); await flush();
assert.equal(notifications, before); assert.equal(notifications, 0);
world.seek(at(5));
clock.destroy(); clock.destroy();
clock.destroy(); clock.destroy();
tick(2000); await flush();
assert.equal(timers.size, 0); assert.equal(timers.size, 0);
assert.equal(b.calls.at(-1).signal.aborted, true); assert.equal(b.calls.at(-1).signal.aborted, true);
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []); assert.deepEqual(clock.getTimeline(), []);
assert.equal(await clock.setTarget(time(5)), false);
}); });
test('the weather time is the world time, both ways', async (t) => { test('the weather time is the world time', async (t) => {
const { clock, world } = harness(t); const { clock, world } = harness(t);
const radar = product('radar', [0, 10, 20]); const radar = product('radar', [0, 10, 20]);
clock.register(radar); clock.register(radar);
assert.equal(world.follows('radar'), true); assert.equal(world.follows('radar'), true);
await clock.setTarget(time(10)); world.seek(at(3));
assert.equal(world.isLive(), false);
assert.equal(world.now(), Date.parse(time(10)));
world.seek(Date.parse(time(3)));
await flush(); await flush();
assert.equal(clock.getState().mode, 'history');
assert.equal(clock.getState().target, time(3)); assert.equal(clock.getState().target, time(3));
assert.equal(radar.shown, time(0)); assert.equal(radar.shown, time(0));
world.goLive(); world.goLive();
@@ -370,30 +302,22 @@ test('the weather time is the world time, both ways', async (t) => {
assert.equal(radar.shown, time(20)); assert.equal(radar.shown, time(20));
}); });
test('world play and pause drive the weather replay', async (t) => { test('world play runs the world on its own at its rate; weather does not drive it', async (t) => {
const { clock, world, tick } = harness(t); const { clock, world, tick } = harness(t);
const radar = product('radar', [0, 10, 20]); const radar = product('radar', [0, 10, 20]);
clock.register(radar); clock.register(radar);
world.seek(Date.parse(time(0))); world.seek(at(0));
await flush(); await flush();
world.play(); world.play({ rate: 60 });
assert.equal(clock.getState().playing, true); assert.equal(world.getState().stepping, false);
assert.equal(world.isPlaying(), true); assert.equal(clock.getState().advancing, true);
tick(2000); tick(5_000);
assert.equal(world.now(), at(5), 'five minutes on at a minute a second');
tick(10_000);
await flush(); await flush();
assert.equal(radar.shown, time(10)); assert.equal(world.now(), at(10), 'held at the next frame');
assert.equal(world.now(), Date.parse(time(10)));
world.pause(); world.pause();
assert.equal(clock.getState().playing, false); assert.equal(clock.getState().advancing, false);
clock.play();
assert.equal(world.isPlaying(), true);
clock.pause();
assert.equal(world.isPlaying(), false);
clock.play();
world.goLive();
await flush();
assert.equal(clock.getState().playing, false);
assert.equal(clock.getState().mode, 'latest');
}); });
test('a moving world re-applies frames only when a product changes frame', async (t) => { test('a moving world re-applies frames only when a product changes frame', async (t) => {
@@ -416,15 +340,6 @@ test('a moving world re-applies frames only when a product changes frame', async
assert.equal(radar.shown, time(10)); assert.equal(radar.shown, time(10));
}); });
test('a world without frames to step plays on its own', async (t) => {
const { clock, world, tick } = harness(t);
world.seek(Date.parse(time(0)));
world.play();
assert.equal(clock.getState().playing, false);
tick(1000);
assert.equal(world.now(), Date.parse(time(0)) + 1000);
});
function worldAt(at) { function worldAt(at) {
return createWorldClock({ return createWorldClock({
now: () => at, now: () => at,
@@ -434,73 +349,24 @@ function worldAt(at) {
}); });
} }
test('weather clocks sharing a world hand play over, and the last leaves it live', async () => { test('weather clocks sharing a world: the last one destroyed leaves it live', () => {
const world = worldAt(Date.parse(time(20))); const world = worldAt(Date.parse(time(20)));
const a = createWeatherClock({ world, now: () => Date.parse(time(20)) }); const a = createWeatherClock({ world, now: () => Date.parse(time(20)) });
const b = createWeatherClock({ world, now: () => Date.parse(time(20)) }); const b = createWeatherClock({ world, now: () => Date.parse(time(20)) });
const radarA = product('radar', [0, 10, 20]); a.register(product('radar', [0, 10, 20]));
const radarB = product('radar', [0, 10, 20]); b.register(product('radar', [0, 10, 20]));
a.register(radarA);
b.register(radarB);
await a.play();
assert.equal(world.isPlaying(), true);
world.pause();
assert.equal(a.getState().playing, false);
assert.equal(world.isPlaying(), false);
await a.play();
world.goLive();
assert.equal(a.getState().playing, false);
b.destroy();
assert.equal(world.follows('radar'), true);
world.seek(Date.parse(time(0))); world.seek(Date.parse(time(0)));
world.play(); world.play();
assert.equal(a.getState().playing, true); b.destroy();
assert.equal(world.follows('radar'), true);
assert.equal(world.isLive(), false);
assert.equal(world.isPlaying(), true);
a.destroy(); a.destroy();
assert.equal(world.follows('radar'), false); assert.equal(world.follows('radar'), false);
assert.equal(world.isLive(), true); assert.equal(world.isLive(), true);
assert.equal(world.isPlaying(), false); assert.equal(world.isPlaying(), false);
}); });
test('pausing from inside the first playing notice stops the world too', async (t) => {
const { clock, world } = harness(t);
clock.register(product('radar', [0, 10, 20]));
const off = clock.subscribe((state) => {
if (state.playing) {
off();
clock.pause();
}
});
await clock.play();
assert.equal(clock.getState().playing, false);
assert.equal(world.isPlaying(), false);
});
test('a step holds a world that was playing on its own', async (t) => {
const { clock, world } = harness(t);
world.seek(Date.parse(time(0)));
world.play();
clock.register(product('radar', [0, 10, 20]));
await clock.step(1);
assert.equal(world.isPlaying(), false);
assert.equal(world.now(), Date.parse(time(10)));
});
test('a target superseded from a listener resolves false', async (t) => {
const { clock } = harness(t);
const radar = product('radar', [0, 10, 20]);
clock.register(radar);
await flush();
let redirected = false;
clock.subscribe((state) => {
if (state.target === time(0) && !redirected) {
redirected = true;
void clock.setTarget(time(10));
}
});
assert.equal(await clock.setTarget(time(0)), false);
assert.equal(radar.shown, time(10));
});
test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => { test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => {
const { clock, world } = harness(t); const { clock, world } = harness(t);
assert.equal(world.coverage(), null); assert.equal(world.coverage(), null);
+40 -8
View File
@@ -53,6 +53,37 @@ const utc = (value) =>
? `${formatUtcDayMonth(value)} ${value.slice(11, 16)} UTC` ? `${formatUtcDayMonth(value)} ${value.slice(11, 16)} UTC`
: t('Unavailable'); : t('Unavailable');
/**
* Move `world`, the world clock, as its timeline does: `play` toggles play
* (from the start of the covered time when live), `latest` goes live, and
* `step` (-1 or 1) goes to the previous or next mark, a weather frame among
* them, as the timeline's arrow keys do.
*/
function moveWorldTime(world, { play, latest, step }) {
if (!world) return;
if (play === true) {
const state = world.getState();
if (state.playing) world.pause();
else {
const covered = world.coverage();
if (state.live && covered) world.seek(covered.start);
world.play({ continuous: true });
}
}
if (latest === true) world.goLive();
if (step === -1 || step === 1) {
const at = world.getState().timeMs;
const marks = world.coverage()?.marks ?? [];
const to =
step < 0
? marks.findLast((mark) => mark < at)
: marks.find((mark) => mark > at);
if (to === undefined) return;
world.pause();
world.seek(to);
}
}
/** A per-application observation layer, using the existing layer lifecycle and /** A per-application observation layer, using the existing layer lifecycle and
* row controls. Its time is the world clock's, so a link made away from live * row controls. Its time is the world clock's, so a link made away from live
* opens its imagery at that time; one made live opens the latest. The feed * opens its imagery at that time; one made live opens the latest. The feed
@@ -254,7 +285,10 @@ export function createWeatherLayer({
) )
return; return;
const times = manifest?.times ?? []; const times = manifest?.times ?? [];
const next = times[(times.indexOf(time) + 1) % times.length]; // The world plays on past the newest frame; this layer's own replay
// (without a clock) goes round to the first.
const after = times.indexOf(time) + 1;
const next = clock ? times[after] : times[after % times.length];
if (!next || next === time) return; if (!next || next === time) return;
try { try {
await rendering.prefetch?.(manifest, next, { infrared }); await rendering.prefetch?.(manifest, next, { infrared });
@@ -528,11 +562,9 @@ export function createWeatherLayer({
}), }),
); );
} }
if (clock && enabled) { // With a clock, time is the world's: these move the world clock as its
if (params.play === true) void clock.togglePlay(); // timeline does, and every layer that follows it moves along.
if (params.latest === true) void clock.latest(); if (clock && enabled) moveWorldTime(clock.world, params);
if ([-1, 1].includes(params.step)) void clock.step(params.step);
}
if ( if (
!clock && !clock &&
params.play === true && params.play === true &&
@@ -897,14 +929,14 @@ export function createWeatherLayer({
const shared = clock?.getState(); const shared = clock?.getState();
return { return {
...rendering?.getDiagnostics(), ...rendering?.getDiagnostics(),
playing: shared ? shared.playing : playing && !hostHidden, playing: shared ? shared.advancing : playing && !hostHidden,
followLatest: isLatest(), followLatest: isLatest(),
...(shared ...(shared
? { ? {
clock: { clock: {
mode: shared.mode, mode: shared.mode,
target: shared.target, target: shared.target,
playing: shared.playing, playing: shared.advancing,
}, },
} }
: {}), : {}),
+79 -32
View File
@@ -35,6 +35,16 @@ const deferred = () => {
const flush = async () => { const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve(); for (let i = 0; i < 30; i++) await Promise.resolve();
}; };
// Time moves on the world clock the weather clock follows, as the timeline
// moves it.
const seekTo = (clock, time) => {
clock.world.seek(Date.parse(time));
return flush();
};
const goLive = (clock) => {
clock.world.goLive();
return flush();
};
function event() { function event() {
const listeners = new Set(); const listeners = new Set();
return { return {
@@ -1765,7 +1775,7 @@ test('a delayed installation failure retains the replacement and does not throw
h.rendering.clear(); h.rendering.clear();
}); });
test('one row steps every registered observation; missing frames hide and latest restores each product', async (t) => { test('a row steps the world clock through every registered observation; missing frames hide and latest goes live', async (t) => {
const clock = createWeatherClock(); const clock = createWeatherClock();
const radar = layerHarness({ clock }); const radar = layerHarness({ clock });
const satellite = layerHarness({ const satellite = layerHarness({
@@ -1798,6 +1808,14 @@ test('one row steps every registered observation; missing frames hide and latest
.chips.filter(({ id }) => .chips.filter(({ id }) =>
['previous', 'play', 'next', 'latest'].includes(id), ['previous', 'play', 'next', 'latest'].includes(id),
); );
// As the timeline's arrow keys do, a step back from live goes to the
// newest frame, and the next one back to the frame before it.
radar.layer.setParams({ step: -1 });
await flush();
assert.equal(clock.getState().target, times[2]);
assert.equal(clock.world.isPlaying(), false);
for (const h of [radar, satellite]) h.stages.at(-1)?.finish();
await flush();
radar.layer.setParams({ step: -1 }); radar.layer.setParams({ step: -1 });
await flush(); await flush();
assert.equal(clock.getState().target, times[1]); assert.equal(clock.getState().target, times[1]);
@@ -1849,6 +1867,33 @@ test('one row steps every registered observation; missing frames hide and latest
assert.equal(clock.getState().products.length, 1); assert.equal(clock.getState().products.length, 1);
}); });
test('a row’s play and latest move the world clock as its timeline does', async (t) => {
const clock = createWeatherClock();
const { world } = clock;
const h = layerHarness({ clock });
t.after(() => {
h.layer.destroy();
clock.destroy();
});
const update = h.layer.update();
await flush();
h.stages.at(-1).finish();
await update;
h.layer.setParams({ play: true });
assert.equal(world.isPlaying(), true);
assert.equal(world.getState().stepping, false, 'the world plays on its own');
assert.ok(
world.now() - Date.parse(times[0]) < 1_000,
'from the start when live',
);
h.layer.setParams({ play: true });
assert.equal(world.isPlaying(), false);
h.layer.setParams({ latest: true });
assert.equal(world.isLive(), true);
await flush();
assert.equal(clock.getState().mode, 'latest');
});
test('history manifest expiry hides imagery without jumping to latest; host resume keeps the target', async (t) => { test('history manifest expiry hides imagery without jumping to latest; host resume keeps the target', async (t) => {
const clock = createWeatherClock(); const clock = createWeatherClock();
let value = snapshot; let value = snapshot;
@@ -1861,7 +1906,7 @@ test('history manifest expiry hides imagery without jumping to latest; host resu
await flush(); await flush();
h.stages.at(-1).finish(); h.stages.at(-1).finish();
await update; await update;
const history = clock.setTarget(times[0]); const history = seekTo(clock, times[0]);
await flush(); await flush();
h.stages.at(-1).finish(); h.stages.at(-1).finish();
await history; await history;
@@ -1930,7 +1975,7 @@ test('satellite global selection uses the three-hour gap and preserves the share
await flush(); await flush();
h.stages.at(-1).finish(); h.stages.at(-1).finish();
await update; await update;
await clock.setTarget(times[0]); await seekTo(clock, times[0]);
assert.equal( assert.equal(
h.layer.getDiagnostics().hidden, h.layer.getDiagnostics().hidden,
true, true,
@@ -1989,7 +2034,8 @@ test('satellite clouds from an hourly feed draw all through each hour and read l
); );
// Forty-five minutes past a frame, between it and the next, it still shows. // Forty-five minutes past a frame, between it and the next, it still shows.
const between = clock.setTarget( const between = seekTo(
clock,
new Date(Date.parse(hourly[0]) + 45 * 60_000).toISOString(), new Date(Date.parse(hourly[0]) + 45 * 60_000).toISOString(),
); );
await flush(); await flush();
@@ -2016,11 +2062,11 @@ test('switching to history cancels a still-loading latest refresh and a later ta
const update = h.layer.update(); const update = h.layer.update();
await flush(); await flush();
const latest = h.stages.at(-1); const latest = h.stages.at(-1);
const oldHistory = clock.setTarget(times[0]); const oldHistory = seekTo(clock, times[0]);
await flush(); await flush();
assert.equal(latest.signal.aborted, true); assert.equal(latest.signal.aborted, true);
const oldStage = h.stages.at(-1); const oldStage = h.stages.at(-1);
const current = clock.setTarget(times[1]); const current = seekTo(clock, times[1]);
await flush(); await flush();
assert.equal(oldStage.signal.aborted, true); assert.equal(oldStage.signal.aborted, true);
h.stages.at(-1).finish(); h.stages.at(-1).finish();
@@ -2330,8 +2376,9 @@ test('failed prefetch leaves the current frame and errors unchanged and releases
assert.deepEqual(h.rendering.getDiagnostics(), before); assert.deepEqual(h.rendering.getDiagnostics(), before);
}); });
test('layer warms the next advertised observation only after a successful playing frame, wrapping at the end', async (t) => { test('as the world plays, the layer warms the next advertised observation after each frame that shows', async (t) => {
const clock = createWeatherClock(); const clock = createWeatherClock();
const { world } = clock;
const h = layerHarness({ clock }); const h = layerHarness({ clock });
const warmed = []; const warmed = [];
let cancels = 0; let cancels = 0;
@@ -2349,45 +2396,45 @@ test('layer warms the next advertised observation only after a successful playin
await flush(); await flush();
h.stages.at(-1).finish(); h.stages.at(-1).finish();
await updated; await updated;
assert.equal(warmed.length, 0); await seekTo(clock, times[0]);
const play = clock.play(); h.stages.at(-1).finish();
await flush(); await flush();
assert.equal(warmed.length, 0, 'paused, nothing is warmed');
world.play({ rate: 1e-9, continuous: true });
assert.equal(clock.getState().advancing, true);
await seekTo(clock, times[1]);
assert.equal(warmed.length, 0, 'still staging'); assert.equal(warmed.length, 0, 'still staging');
h.stages.at(-1).finish(); h.stages.at(-1).finish();
await play; await flush();
assert.deepEqual( assert.deepEqual(
warmed.map(({ time }) => time), warmed.map(({ time }) => time),
[times[0]], [times[2]],
); );
assert.deepEqual(warmed[0].options, { infrared: 100 }); assert.deepEqual(warmed[0].options, { infrared: 100 });
const next = clock.setTarget(times[0]); await seekTo(clock, times[0]);
await flush();
h.stages.at(-1).finish();
await next;
assert.deepEqual(
warmed.map(({ time }) => time),
[times[0], times[1]],
);
const failed = clock.setTarget(times[1]);
await flush();
h.stages.at(-1).finish(false); h.stages.at(-1).finish(false);
await failed;
assert.equal(warmed.length, 2);
const priorCancels = cancels;
clock.pause();
assert.ok(cancels > priorCancels);
const paused = clock.setTarget(times[2]);
await flush(); await flush();
assert.equal(warmed.length, 1, 'a failed frame warms nothing');
await seekTo(clock, times[2]);
h.stages.at(-1).finish(); h.stages.at(-1).finish();
await paused; await flush();
assert.equal(warmed.length, 2); assert.equal(warmed.length, 1, 'the world plays on past the newest frame');
await clock.play(); const priorCancels = cancels;
world.pause();
await flush();
assert.ok(cancels > priorCancels);
await seekTo(clock, times[1]);
h.stages.at(-1).finish();
await flush();
assert.equal(warmed.length, 1);
world.play({ rate: 1e-9, continuous: true });
h.documentRef.hidden = true; h.documentRef.hidden = true;
h.documentRef.emit('visibilitychange'); h.documentRef.emit('visibilitychange');
await flush(); await flush();
h.stages.at(-1).finish(); h.stages.at(-1)?.finish();
await flush(); await flush();
assert.equal(warmed.length, 2, 'hidden products do not prefetch'); assert.equal(warmed.length, 1, 'hidden products do not prefetch');
world.goLive();
}); });
for (const product of ['radar', 'clouds-regional', 'lightning']) { for (const product of ['radar', 'clouds-regional', 'lightning']) {
+7 -3
View File
@@ -383,20 +383,24 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
test('observed history labels wind as a forecast without changing its data or parameters', async () => { test('observed history labels wind as a forecast without changing its data or parameters', async () => {
const { createWeatherClock } = await import('../weather/clock.js'); const { createWeatherClock } = await import('../weather/clock.js');
const clock = createWeatherClock(); const clock = createWeatherClock();
const { world } = clock;
const layer = createWindLayer({ feed: { getSnapshot: async () => snapshot('gfs') }, clock }); const layer = createWindLayer({ feed: { getSnapshot: async () => snapshot('gfs') }, clock });
let changes = 0; let changes = 0;
layer.setRowControlsListener(() => changes++); layer.setRowControlsListener(() => changes++);
const params = layer.getParams(); const params = layer.getParams();
await clock.setTarget('2026-09-14T12:00:00.000Z'); world.seek(Date.parse('2026-09-14T12:00:00.000Z'));
await Promise.resolve();
assert.equal(layer.getRowControls().summary.status, null, 'history does not mask source status'); assert.equal(layer.getRowControls().summary.status, null, 'history does not mask source status');
assert.ok(changes > 0); assert.ok(changes > 0);
assert.match(layer.getRowControls().info, /Forecast · does not follow history/); assert.match(layer.getRowControls().info, /Forecast · does not follow history/);
assert.deepEqual(layer.getParams(), params); assert.deepEqual(layer.getParams(), params);
await clock.latest(); world.goLive();
await Promise.resolve();
assert.equal(layer.getRowControls().summary.status, null); assert.equal(layer.getRowControls().summary.status, null);
layer.destroy(); layer.destroy();
const before = changes; const before = changes;
await clock.setTarget('2026-09-14T12:00:00.000Z'); world.seek(Date.parse('2026-09-14T12:00:00.000Z'));
await Promise.resolve();
assert.equal(changes, before); assert.equal(changes, before);
clock.destroy(); clock.destroy();
}); });
+4 -3
View File
@@ -14,9 +14,10 @@
* src/time/snapshotTimeline.js); otherwise they stay live. * src/time/snapshotTimeline.js); otherwise they stay live.
* *
* Playback is continuous (`cursor + elapsed × rate`) unless a driver takes * Playback is continuous (`cursor + elapsed × rate`) unless a driver takes
* it: weather replay steps its observed frames itself and moves the clock as * it: a driver steps the clock itself and moves it as it goes, so play and
* it goes, so play and pause hand over to it. Several drivers may be attached; * pause hand over to it. Several drivers may be attached; play goes to the
* play goes to the newest that can play, pause to the one playing. * newest that can play, pause to the one playing. Layers that load frames
* (weather) do not drive it: they gate continuous play at their next frame.
*/ */
/** The span a JS Date can hold, either side of 1970. */ /** The span a JS Date can hold, either side of 1970. */
-178
View File
@@ -1,178 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
import { formatUtcDayMonth, t } from '../../i18n/index.js';
import { utcClock } from './utcClock.js';
import UiSlider from '../kit/UiSlider.js';
// Stable native transport with local preview and a coalesced 150 ms drag
// commit. While the handle is dragged the slider keeps the ticks it was
// dragged over; the owner's updates apply once the drag ends.
const props = defineProps({
state: { type: Object, required: true },
sliderClassName: { type: String, default: '' },
heading: { type: Boolean, default: true },
onCommit: { type: Function, default: () => {} },
onPreview: { type: Function, default: () => {} },
onStep: { type: Function, default: () => {} },
onLatest: { type: Function, default: () => {} },
onPlay: { type: Function, default: () => {} },
});
const utc = (tick) =>
Number.isFinite(Date.parse(tick))
? `${formatUtcDayMonth(tick)} ${utcClock(tick)}`
: '';
// What the slider shows: the owner's ticks, index and readout, frozen while
// dragging.
const shown = shallowRef({ ticks: [], index: 0, readout: '' });
let dragging = false;
let timer = null;
let pending = null;
let live = true;
const cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
const sync = () => {
if (dragging) return;
const ticks = [...props.state.ticks];
shown.value = {
ticks,
index: Math.max(0, Math.min(props.state.index, ticks.length - 1)),
readout: props.state.readout || '',
};
};
watch(
() => props.state,
(state) => {
if (state.disabled) {
cancel();
pending = null;
dragging = false;
}
sync();
},
{ immediate: true, flush: 'sync' },
);
onBeforeUnmount(() => {
live = false;
cancel();
});
const disabled = computed(() => Boolean(props.state.disabled));
const latestActive = computed(() => props.state.mode === 'latest');
const playing = computed(() => Boolean(props.state.playing));
function commit() {
cancel();
const value = pending;
pending = null;
if (live && value && !props.state.disabled)
props.onCommit(value.tick, value.index);
}
function preview(event) {
if (props.state.disabled) return;
dragging = true;
const index = Number(event.target.value);
const tick = shown.value.ticks[index];
if (!tick) return;
pending = { tick, index };
// Shown before the owner hears of it: an update from inside onPreview
// must not move the handle back.
shown.value = { ...shown.value, index };
const readout = props.onPreview(tick, index);
// The owner may have ended the drag (disabled) from inside onPreview.
if (!dragging) return;
if (typeof readout === 'string') shown.value = { ...shown.value, readout };
if (timer === null) timer = setTimeout(commit, 150);
}
// The handle shows the shown index again. A drag that ends where it began
// (an input and a cancel, or a refused commit, before the next render)
// leaves the slider's model unchanged, so it would not render.
const place = (input) => {
const index = String(shown.value.index);
if (input && input.value !== index) input.value = index;
};
function change(event) {
if (props.state.disabled) return;
const index = Number(event.target.value);
pending = { tick: shown.value.ticks[index], index };
dragging = false;
commit();
sync();
place(event.target);
}
function reset(event) {
cancel();
pending = null;
dragging = false;
sync();
place(event?.target);
}
function blur(event) {
if (dragging) change(event);
}
const act = (fn) => {
reset();
if (live && !props.state.disabled) fn();
};
</script>
<template>
<section class="rail-timeline"
><div v-if="heading" class="panel-title">{{ t('Observed history') }}</div
><div class="rail-timeline-track"
><button
type="button"
class="data-toggle-chip"
:title="t('Earlier observation')"
:disabled="disabled || state.index <= 0"
@click="act(() => onStep(-1))"
>‹</button
><UiSlider
:class="sliderClassName || undefined"
min="0"
:max="String(Math.max(0, shown.ticks.length - 1))"
step="1"
:model-value="String(shown.index)"
:disabled="disabled"
:aria-label="t('Observed history')"
:aria-valuetext="utc(shown.ticks[shown.index])"
@input="preview"
@change="change"
@pointercancel="reset"
@blur="blur"
/><button
type="button"
class="data-toggle-chip"
:title="t('Later observation')"
:disabled="disabled || state.index >= state.ticks.length - 1"
@click="act(() => onStep(1))"
>›</button
><div class="rail-timeline-endpoints"
><span>{{ utcClock(shown.ticks[0]) }}</span
><span>{{ utcClock(shown.ticks.at(-1)) }}</span></div
></div
><div class="rail-timeline-controls"
><button
type="button"
:class="`data-toggle-chip${latestActive ? ' active' : ''}`"
:title="t('Newest frame per product')"
:disabled="disabled"
:aria-pressed="String(latestActive)"
@click="act(onLatest)"
>{{ t('Latest') }}</button
><button
type="button"
:class="`data-toggle-chip${playing ? ' active' : ''}`"
:title="t('Replay observed history')"
:disabled="disabled"
:aria-pressed="String(playing)"
@click="act(onPlay)"
>{{ playing ? t('Pause') : t('Play') }}</button
><span class="rail-timeline-readout">{{ shown.readout }}</span></div
></section
>
</template>
-56
View File
@@ -1,56 +0,0 @@
import { h, render } from 'vue';
import RailTimeline from './rail/RailTimeline.vue';
export { utcClock } from './rail/utcClock.js';
/**
* Stable native transport with local preview and a coalesced 150 ms drag
* commit. Renders the RailTimeline component into `container` and patches it
* synchronously on every update.
*/
export function createRailTimeline({
container,
document = container?.ownerDocument,
sliderClassName = '',
heading = true,
onCommit = () => {},
onPreview = () => {},
onStep = () => {},
onLatest = () => {},
onPlay = () => {},
} = {}) {
if (!document?.createElement || !container) return null;
let destroyed = false;
const paint = (state) =>
render(
h(RailTimeline, {
state,
sliderClassName,
heading,
onCommit,
onPreview,
onStep,
onLatest,
onPlay,
}),
container,
);
paint({
ticks: [],
index: 0,
mode: 'latest',
playing: false,
readout: '',
disabled: true,
});
return {
update(state) {
if (!destroyed) paint(state);
},
destroy() {
if (destroyed) return;
destroyed = true;
render(null, container);
},
};
}
-178
View File
@@ -1,178 +0,0 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { assertSameNode } from '../testSupport/domAssert.mjs';
import { nextTick } from 'vue';
import { createRailTimeline } from './railTimeline.js';
import { railDocumentFixture as railFixture } from './railTestFixture.mjs';
const ticks = [
'2026-09-22T01:00:00Z',
'2026-09-22T01:05:00Z',
'2026-09-22T01:10:00Z',
];
const props = {
ticks,
index: 2,
mode: 'latest',
playing: false,
readout: 'Latest',
disabled: false,
};
test('preview is immediate, commits are coalesced and the drag DOM and tick mapping stay stable', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const f = railFixture();
const previews = [];
const commits = [];
const view = createRailTimeline({
...f,
onPreview: (tick) => {
previews.push(tick);
return tick;
},
onCommit: (tick) => commits.push(tick),
});
view.update(props);
const slider = f.find((n) => n.tagName === 'INPUT');
const row = slider.parentElement;
assert.equal(slider.getAttribute('aria-label'), 'Observed history');
slider.value = '0';
slider.dispatchEvent(new Event('input'));
assert.deepEqual(commits, []);
assert.deepEqual(previews, [ticks[0]]);
await nextTick();
assert.equal(slider.getAttribute('aria-valuetext'), '09-22 01:00 UTC');
view.update({
...props,
ticks: ticks.slice(1),
index: 1,
readout: 'Refresh',
});
assertSameNode(slider.parentElement, row);
assertSameNode(row.children[1], slider);
assert.equal(slider.value, '0');
assert.equal(slider.max, '2');
assert.equal(
f.find((n) => n.className === 'rail-timeline-readout').textContent,
ticks[0],
);
t.mock.timers.tick(149);
assert.equal(commits.length, 0);
slider.value = '1';
slider.dispatchEvent(new Event('input'));
t.mock.timers.tick(1);
assert.deepEqual(commits, [ticks[1]]);
slider.value = '2';
slider.dispatchEvent(new Event('input'));
slider.dispatchEvent(new Event('change'));
assert.deepEqual(commits, [ticks[1], ticks[2]]);
t.mock.timers.tick(150);
assert.equal(commits.length, 2);
await nextTick();
assert.equal(slider.max, '1', 'release applies the latest update');
view.destroy();
});
test('identical updates write nothing; controls call actions and destruction cancels commits', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const f = railFixture();
const calls = [];
const view = createRailTimeline({
...f,
onCommit: () => calls.push('commit'),
onStep: (n) => calls.push(n),
onLatest: () => calls.push('latest'),
onPlay: () => calls.push('play'),
});
view.update({ ...props, index: 1 });
f.writes();
view.update({ ...props, index: 1 });
assert.equal(f.writes(), 0);
for (const label of ['‹', '›', 'Latest', 'Play'])
f.find((n) => n.textContent === label).click();
assert.deepEqual(calls, [-1, 1, 'latest', 'play']);
view.update({ ...props, playing: true });
assert.ok(f.find((n) => n.textContent === 'Pause'));
const slider = f.find((n) => n.tagName === 'INPUT');
slider.value = '0';
slider.dispatchEvent(new Event('change'));
assert.equal(calls.at(-1), 'commit');
slider.dispatchEvent(new Event('input'));
view.destroy();
t.mock.timers.tick(150);
assert.equal(calls.length, 5);
assert.equal(f.container.children.length, 0);
});
test('observed history label and endpoint times track the advertised tick range', () => {
const f = railFixture();
const view = createRailTimeline(f);
view.update(props);
assert.equal(
f.find((n) => n.className === 'panel-title').textContent,
'Observed history',
);
const endpoints = f.find((n) => n.className === 'rail-timeline-endpoints');
assert.equal(endpoints.parentElement.className, 'rail-timeline-track');
assert.deepEqual(
[...endpoints.children].map((n) => n.textContent),
['01:00 UTC', '01:10 UTC'],
);
view.update({ ...props, ticks: ticks.slice(1) });
assert.equal(endpoints.children[0].textContent, '01:05 UTC');
view.destroy();
});
test('an update from inside the preview neither moves the handle back nor survives a disable', async () => {
const f = railFixture();
let view;
let next = null;
const commits = [];
view = createRailTimeline({
...f,
onPreview: () => {
if (next) view.update(next);
return 'preview';
},
onCommit: (tick, index) => commits.push(index),
});
view.update(props);
const slider = f.find((n) => n.tagName === 'INPUT');
next = { ...props, readout: 'Refresh' };
slider.value = '0';
slider.dispatchEvent(new Event('input'));
assert.equal(slider.value, '0');
slider.dispatchEvent(new Event('change'));
assert.deepEqual(commits, [0]);
next = { ...props, disabled: true };
slider.value = '1';
slider.dispatchEvent(new Event('input'));
await nextTick();
assert.equal(slider.value, '2', 'the disabled owner keeps its position');
assert.equal(slider.getAttribute('aria-valuetext'), '09-22 01:10 UTC');
view.destroy();
});
test('a drag that ends where it began puts the handle back on the shown index', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const f = railFixture();
const commits = [];
const view = createRailTimeline({
...f,
onCommit: (tick) => commits.push(tick),
});
view.update(props);
await nextTick();
const slider = f.find((n) => n.tagName === 'INPUT');
slider.value = '0';
slider.dispatchEvent(new Event('input'));
slider.dispatchEvent(new Event('pointercancel'));
await nextTick();
assert.equal(slider.value, '2', 'a cancelled drag');
assert.deepEqual(commits, []);
// A refused commit: the owner keeps its index.
slider.value = '1';
slider.dispatchEvent(new Event('input'));
slider.dispatchEvent(new Event('change'));
await nextTick();
assert.deepEqual(commits, [ticks[1]]);
assert.equal(slider.value, '2', 'a refused commit');
view.destroy();
});
+5 -5
View File
@@ -125,8 +125,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
/** /**
* Play runs the track at the speed chosen, from the start when it is at * Play runs the track at the speed chosen, from the start when it is at
* its end (at live, say), as a video plays again from the top. It runs * its end (at live, say), as a video plays again from the top. It runs
* the clock itself, past weather's own replay: weather follows the time * the clock itself: weather follows the time frame by frame like every
* frame by frame like every other layer the track covers. * other layer the track covers.
*/ */
togglePlay() { togglePlay() {
const { playing, live, timeMs, span } = time.value; const { playing, live, timeMs, span } = time.value;
@@ -138,9 +138,9 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
}, },
speed, speed,
/** /**
* Choose how fast play runs; playing, it changes at once. Weather's own * Choose how fast play runs; playing, it changes at once. A driver steps
* replay steps at its own pace, so a speed chosen while it plays takes * at its own pace, so a speed chosen while one plays takes playback over
* playback over from where it is. * from where it is.
*/ */
setSpeed(next) { setSpeed(next) {
if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return; if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return;
+4 -46
View File
@@ -56,8 +56,7 @@
} }
/* Card typography follows the layer rows and app tokens. */ /* Card typography follows the layer rows and app tokens. */
.weather-readout, .weather-readout,
.rail-card, .rail-card {
.rail-timeline {
min-width: 0; min-width: 0;
color: var(--text-dim); color: var(--text-dim);
font: 9px/1.45 var(--font-mono); font: 9px/1.45 var(--font-mono);
@@ -128,7 +127,7 @@
text-align: right; text-align: right;
} }
.rail-card-line, .rail-card-line,
.rail-timeline-readout { .weather-frame-time {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 9px; font-size: 9px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -188,54 +187,13 @@ html:not([lang='en']) .weather-card [data-line-id='status'] {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.rail-card [hidden], .rail-card [hidden],
.weather-timeline-block[hidden], .weather-frame-time[hidden],
.weather-observed-group[hidden] { .weather-observed-group[hidden] {
display: none !important; display: none !important;
} }
.rail-timeline { .weather-frame-time {
padding: 5px; padding: 5px;
border-bottom: 1px solid var(--weather-card-border); border-bottom: 1px solid var(--weather-card-border);
}
.rail-timeline-track {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 3px 8px;
margin-top: 6px;
}
.rail-timeline-track input {
min-width: 0;
width: 100%;
margin: 0;
}
.rail-timeline-endpoints {
display: flex;
grid-column: 2;
justify-content: space-between;
flex-wrap: wrap;
gap: 3px 8px;
font: 8px/1.45 var(--font-mono);
font-variant-numeric: tabular-nums;
}
.rail-timeline-endpoints span {
white-space: nowrap;
}
.rail-timeline-endpoints span:last-child {
margin-left: auto;
}
.rail-timeline-controls .data-toggle-chip {
flex: 0 0 auto;
}
.rail-timeline-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.rail-timeline-readout {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--text-secondary); color: var(--text-secondary);
+5 -13
View File
@@ -1,15 +1,14 @@
<script setup> <script setup>
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import RailCards from '../rail/RailCards.vue'; import RailCards from '../rail/RailCards.vue';
import RailTimeline from '../rail/RailTimeline.vue';
// Active weather: forecast and event cards, then the observed products with // Active weather: forecast and event cards, then the observed products with
// their shared history timeline. The owner computes every value shown. // the time of the frame they show. The world timeline moves time; the owner
// computes every value shown.
defineProps({ defineProps({
view: { type: Object, required: true }, view: { type: Object, required: true },
onParams: { type: Function, default: () => {} }, onParams: { type: Function, default: () => {} },
onOpen: { type: Function, default: () => {} }, onOpen: { type: Function, default: () => {} },
transport: { type: Object, required: true },
}); });
</script> </script>
@@ -31,16 +30,9 @@ defineProps({
:aria-label="t('Observed history')" :aria-label="t('Observed history')"
><h3 class="panel-title">{{ t('Observed history') }}</h3 ><h3 class="panel-title">{{ t('Observed history') }}</h3
><div class="weather-observed-scope">{{ view.scope }}</div ><div class="weather-observed-scope">{{ view.scope }}</div
><div class="weather-timeline-block" :hidden="!view.observed" ><div class="weather-frame-time" :hidden="!view.frameTime">{{
><RailTimeline view.frameTime
:state="view.timeline" }}</div
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" ><div class="weather-cards"
><RailCards ><RailCards
:cards="view.observedCards" :cards="view.observedCards"
+14 -38
View File
@@ -88,13 +88,6 @@ export function createWeatherPanel({
openPanels.set(page, id); openPanels.set(page, id);
render(); render();
}, },
transport: {
onCommit: (tick) => clock?.setTarget(tick),
onPreview: historyTime,
onStep: (direction) => clock?.step(direction),
onLatest: () => clock?.latest(),
onPlay: () => clock?.togglePlay(),
},
}; };
// The readout patches synchronously, so a newly opened card can be measured. // The readout patches synchronously, so a newly opened card can be measured.
const paint = (view) => const paint = (view) =>
@@ -103,14 +96,7 @@ export function createWeatherPanel({
hidden: true, hidden: true,
observed: false, observed: false,
scope: '', scope: '',
timeline: { frameTime: '',
ticks: [],
index: 0,
mode: 'latest',
playing: false,
readout: '',
disabled: true,
},
cards: [], cards: [],
observedCards: [], observedCards: [],
}); });
@@ -125,32 +111,22 @@ export function createWeatherPanel({
.filter(({ summary }) => summary) .filter(({ summary }) => summary)
.sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id)); .sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id));
const observed = active.filter(({ id }) => OBSERVED.has(id)); const observed = active.filter(({ id }) => OBSERVED.has(id));
const showTimeline = observed.length > 0; const showObserved = observed.length > 0;
const names = { const names = {
'weather-radar': t('Rain radar'), 'weather-radar': t('Rain radar'),
'weather-satellite': t('Satellite clouds'), 'weather-satellite': t('Satellite clouds'),
'weather-lightning': t('Lightning density'), 'weather-lightning': t('Lightning density'),
}; };
const index = // While live, the newest frame and its age; at a past time, the timeline
state.mode === 'latest' // time alone. The world timeline moves the time.
? state.timeline.length - 1 const shownTime =
: Math.max( state.mode === 'latest' ? state.timeline.at(-1) : state.target;
0, const frameTime =
state.timeline.findLastIndex( showObserved && shownTime
(tick) => Date.parse(tick) <= Date.parse(state.target), ? state.mode === 'history'
), ? clockTime(shownTime)
); : historyTime(shownTime)
const timeline = { : '';
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 models = active.map((entry) => {
const { id, icon, summary, legend = [], list } = entry; const { id, icon, summary, legend = [], list } = entry;
// Discrete classes (storm geometry, AQHI bands) list swatches, not a ramp. // Discrete classes (storm geometry, AQHI bands) list swatches, not a ramp.
@@ -234,9 +210,9 @@ export function createWeatherPanel({
const hidden = active.length === 0; const hidden = active.length === 0;
paint({ paint({
hidden, hidden,
observed: showTimeline, observed: showObserved,
scope: observed.map(({ id }) => names[id]).join(' · '), scope: observed.map(({ id }) => names[id]).join(' · '),
timeline, frameTime,
cards: models.filter(({ id }) => !OBSERVED.has(id)), cards: models.filter(({ id }) => !OBSERVED.has(id)),
observedCards: models.filter(({ id }) => OBSERVED.has(id)), observedCards: models.filter(({ id }) => OBSERVED.has(id)),
}); });
+24 -36
View File
@@ -54,10 +54,6 @@ function fixture() {
listeners.add(fn); listeners.add(fn);
return () => listeners.delete(fn); return () => listeners.delete(fn);
}, },
setTarget: (tick) => calls.push(['target', tick]),
latest: () => calls.push(['latest']),
togglePlay: () => calls.push(['play']),
step: (n) => calls.push(['step', n]),
}; };
return { return {
...f, ...f,
@@ -74,39 +70,36 @@ function fixture() {
}, },
}; };
} }
const timelineHost = (f) => const frameTime = (f) => f.find((n) => n.className === 'weather-frame-time');
f.find((n) => n.className === 'weather-timeline-block');
const card = (f, id) => f.find((n) => n.dataset.cardId === id); const card = (f, id) => f.find((n) => n.dataset.cardId === id);
const line = (f, id, name) => const line = (f, id, name) =>
f.find((n) => n.dataset.lineId === name, card(f, id)); f.find((n) => n.dataset.lineId === name, card(f, id));
test('timeline stays visible with any observed product; native preview and transport use the clock', (t) => { test('the frame time shows with any observed product; the panel has no time controls of its own', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({
apis: ['Date'],
now: Date.parse('2026-09-22T01:17:00Z'),
});
const f = fixture(); const f = fixture();
const view = createWeatherPanel(f); const view = createWeatherPanel(f);
view.update([wind]); view.update([wind]);
assert.equal(timelineHost(f).hidden, true); assert.equal(frameTime(f).hidden, true);
view.update([wind, radar]); view.update([wind, radar]);
assert.equal(timelineHost(f).hidden, false); assert.equal(frameTime(f).hidden, false);
assert.equal(frameTime(f).textContent, '01:05 UTC · 12 min ago');
f.state({ timeline: [ticks[0]] }); f.state({ timeline: [ticks[0]] });
assert.equal(timelineHost(f).hidden, false); assert.equal(frameTime(f).textContent, '01:00 UTC · 17 min ago');
assert.equal(f.find((n) => n.tagName === 'INPUT').disabled, true);
f.state({ timeline: [] }); f.state({ timeline: [] });
assert.equal(timelineHost(f).hidden, false); assert.equal(frameTime(f).hidden, true);
f.state({ timeline: ticks }); f.state({ timeline: ticks, mode: 'history', target: ticks[0] });
assert.equal(timelineHost(f).hidden, false); assert.equal(frameTime(f).textContent, '01:00 UTC');
const slider = f.find((n) => n.tagName === 'INPUT'); assert.equal(
slider.value = '0'; f.find((n) => n.tagName === 'INPUT'),
slider.dispatchEvent(new Event('input')); null,
assert.deepEqual(f.calls, []); );
slider.dispatchEvent(new Event('change')); assert.equal(
assert.deepEqual(f.calls, [['target', ticks[0]]]); f.find((n) => ['Play', 'Latest'].includes(n.textContent)),
f.find((n) => n.textContent === 'Latest' && n.tagName === 'BUTTON').click(); null,
f.find((n) => n.textContent === 'Play').click(); );
assert.deepEqual(f.calls.slice(1), [['latest'], ['play']]);
slider.dispatchEvent(new Event('input'));
view.update([wind]);
t.mock.timers.tick(150);
assert.equal(f.calls.length, 3, 'hiding cancels pending drag');
view.destroy(); view.destroy();
assert.equal(f.listeners.size, 0); assert.equal(f.listeners.size, 0);
}); });
@@ -179,7 +172,7 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage,
], ],
}; };
view.update([cyclone]); view.update([cyclone]);
assert.equal(timelineHost(f).hidden, true); assert.equal(frameTime(f).hidden, true);
assert.match( assert.match(
line(f, cyclone.id, 'time').textContent, line(f, cyclone.id, 'time').textContent,
/JULIO.*Hurricane.*Advisory 10.*UTC/, /JULIO.*Hurricane.*Advisory 10.*UTC/,
@@ -410,7 +403,7 @@ test('cyclones lead, and observed history owns one bordered group with only acti
group.children[1].textContent, group.children[1].textContent,
'Rain radar · Satellite clouds · Lightning density', 'Rain radar · Satellite clouds · Lightning density',
); );
assertSameNode(group.children[2], timelineHost(f)); assertSameNode(group.children[2], frameTime(f));
assert.deepEqual( assert.deepEqual(
[...group.children[3].children].map((n) => n.dataset.cardId), [...group.children[3].children].map((n) => n.dataset.cardId),
['weather-radar', 'weather-satellite', 'weather-lightning'], ['weather-radar', 'weather-satellite', 'weather-lightning'],
@@ -672,13 +665,8 @@ test('history time keeps the hour whatever the length of the localized date', ()
const f = fixture(); const f = fixture();
const view = createWeatherPanel(f); const view = createWeatherPanel(f);
view.update([wind, radar]); view.update([wind, radar]);
const ends = f.find((n) => n.className === 'rail-timeline-endpoints');
assert.deepEqual(
[...ends.children].map((n) => n.textContent),
['01:00 UTC', '01:05 UTC'],
locale,
);
f.state({ mode: 'history', target: ticks[1] }); f.state({ mode: 'history', target: ticks[1] });
assert.equal(frameTime(f).textContent, '01:05 UTC', locale);
assert.match( assert.match(
line(f, radar.id, 'time').textContent, line(f, radar.id, 'time').textContent,
/(^|[^\d:])01:05 UTC/, /(^|[^\d:])01:05 UTC/,