diff --git a/changelog.d/0000-timeline-one-control-and-marks.md b/changelog.d/0000-timeline-one-control-and-marks.md
new file mode 100644
index 000000000..fdf384488
--- /dev/null
+++ b/changelog.d/0000-timeline-one-control-and-marks.md
@@ -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).
diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md
index 003b15248..c268a5206 100644
--- a/docs/APPLICATION.md
+++ b/docs/APPLICATION.md
@@ -187,7 +187,10 @@ instances. Configure sources with `application/sources` before registration or
state restoration, and release them after the consumers stop. The catalog
constructor (`application/layers`) also requires `wind`, `weather` and
`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
`getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or
detail image and an optional `getFrameTile({ product, time, z, x, y, size,
diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index b30c3aba1..e7105aae3 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -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
scroll on an open-card change, with one write, never on a refresh.
-Compose `createRailCards({ container, document, onParams, onOpen })` and
-`createRailTimeline({ container, document, onCommit, onPreview, onStep,
-onLatest, onPlay })`. Cards reconcile `{ id, title, badge, open, compact,
+Compose `createRailCards({ container, document, onParams, onOpen })`. Rails
+have no time controls of their own; the world timeline moves time. Cards
+reconcile `{ id, title, badge, open, compact,
compactStatus, blocks }` in place. Their full-width native button headers expose
`aria-expanded` and `aria-controls`; articles expose `data-open`. The feature
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.
Weather orders cyclones, wind, then a bordered Observed history group containing
-the timeline and active radar, satellite and lightning cards. The group heading
-and scope name its active products. The timeline stays visible whenever any
-observed product is enabled; fewer than two ticks disables transport. Pass
-`heading: false` to the timeline when the containing group owns the heading.
+the frame time and active radar, satellite and lightning cards. The group
+heading and scope name its active products. The frame time shows whenever any
+observed product is enabled and has a frame; the world timeline moves time.
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
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,
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;
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
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index feb6ce722..14a754cb8 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -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:
NOAA Rain radar (CONUS), Satellite clouds (regional GOES or slower global infrared),
and 15-minute lightning density (Americas and Pacific). One transient observed
-clock drives all enabled observation rows. Earlier/Later use the sorted union of
-non-suspended products' advertised times; each product selects its nearest frame
-at or before the requested UTC time, never a future frame or an interpolation.
+clock moves all enabled observation rows with the world clock; the world
+timeline is the only time control. Each product's advertised times are marks on
+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
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
@@ -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.
Manifest rollover preserves the requested history time, even when frames expire.
-Latest follows each product's own newest frame, so its timestamps can differ.
-Playback advances two seconds after all products settle, wraps, and stops with
-fewer than two union times or when all products are suspended. Hidden documents,
-reduced motion and unavailable imagery hosts suspend products. Re-enabling or
+Live follows each product's own newest frame, so its timestamps can differ.
+The timeline's Play runs the world clock at the chosen speed and stops at each
+product's next frame until that frame loads (for at most eight seconds), so
+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
“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
-panel's cards and timeline, not in the rows. The viewer clock is untouched.
+Source, opacity and coverage navigation live in the WEATHER panel's cards, not
+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
is transient and shared links open latest. Rendering retains the previous frame
when an incoming frame fails; no eligible historical frame instead hides imagery.
diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md
index 3dcd7f63b..91a9d4228 100644
--- a/docs/VUE-MIGRATION.md
+++ b/docs/VUE-MIGRATION.md
@@ -153,9 +153,9 @@ a feature supplying only its accent.
`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
keep their imperative entry points until their owners are components:
- the rail cards, card blocks and timeline (`src/ui/rail/`) render through
- Vue's synchronous `render()` behind `createRailCards()`,
- `createRailCardBlocks()` and `createRailTimeline()`. Order: Scenes (done: `SceneOptions` and
+ the rail cards and card blocks (`src/ui/rail/`) render through
+ Vue's synchronous `render()` behind `createRailCards()` and
+ `createRailCardBlocks()`. Order: Scenes (done: `SceneOptions` and
`SceneShotList` over the `gev-scenes` store; the panel's buttons, status
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
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 8960c13f8..849f73e1f 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -936,8 +936,6 @@
"src/ui/weatherPanel.test.mjs",
"src/ui/railCards.js",
"src/ui/railCards.test.mjs",
- "src/ui/railTimeline.js",
- "src/ui/railTimeline.test.mjs",
"src/layers/weather/imageryOrder.js",
"src/layers/weather/imageryOrder.test.mjs",
"server/providers/cyclones.js",
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index cd6d60a34..b819fa2c2 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -804,7 +804,6 @@
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue",
- "src/ui/railTimeline.js",
"src/ui/clearLayersControl.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
@@ -837,7 +836,6 @@
"src/ui/rail/RailCard.vue",
"src/ui/rail/RailCards.vue",
"src/ui/rail/utcClock.js",
- "src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue",
"src/ui/newsPanel.js",
@@ -1217,7 +1215,6 @@
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue",
- "src/ui/railTimeline.js",
"src/ui/layers.js",
"src/ui/leftPanelRail.js",
"src/ui/location.js",
@@ -1312,7 +1309,6 @@
"src/ui/rail/RailCard.vue",
"src/ui/rail/RailCards.vue",
"src/ui/rail/utcClock.js",
- "src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue",
"src/ui/newsPanel.js",
@@ -3168,7 +3164,6 @@
"src/ui/layerPanel/RowSwitch.vue",
"src/ui/layerPanel/RowRange.vue",
"src/ui/layerPanel/RowSlider.vue",
- "src/ui/railTimeline.js",
"src/ui/layers.js",
"src/ui/leftPanelRail.js",
"src/ui/location.js",
@@ -3724,7 +3719,6 @@
"src/ui/rail/RailCards.vue",
"src/ui/rail/RailCardBlocks.vue",
"src/ui/rail/utcClock.js",
- "src/ui/rail/RailTimeline.vue",
"src/ui/rail/rangeValue.js",
"src/ui/weather/WeatherReadout.vue",
"src/ui/newsPanel.js",
diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs
index dadf2c541..8e742ff6b 100644
--- a/src/app/constructCatalog.test.mjs
+++ b/src/app/constructCatalog.test.mjs
@@ -6,6 +6,11 @@ import { createStandaloneLayerSources } from '../standalone/layerSources.js';
import { catalogControlServices } from './catalog.js';
import { LayerLifecycle } from '../data/lifecycle.js';
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) {
const sources = createStandaloneLayerSources();
@@ -27,6 +32,7 @@ test('catalogs construct distinct layers and classification from their supplied
t.after(() => {
a.abort();
b.abort();
+ worldClock.goLive();
});
const callsA = [];
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',
);
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(
first.get('wind').getRowControls().info,
/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(second.militaryRegistry.isMilitaryIcao('def456'), true);
a.abort();
+ worldClock.seek(Date.parse('2026-09-21T13:00:00.000Z'));
+ await flush();
assert.equal(
- await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'),
- false,
+ first.weatherClock.getState().target,
+ '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(first.militaryRegistry.isMilitaryIcao('abc123'), false);
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json
index 5fe57db79..f5d368a45 100644
--- a/src/i18n/locales/ar.json
+++ b/src/i18n/locales/ar.json
@@ -2542,12 +2542,8 @@
"PAUSE": "إيقاف مؤقت",
"RESUME": "استئناف",
"Clear reading": "مسح القراءة",
- "Earlier observation": "رصد أسبق",
"Observed history": "سجل الأرصاد",
- "Later observation": "رصد لاحق",
"Latest": "الأحدث",
- "Newest frame per product": "أحدث إطار لكل منتج",
- "Replay observed history": "إعادة تشغيل سجل الأرصاد",
"Imagery on Esri · Google 3D returns when cleared": "الصور على Esri · تعود Google 3D عند المسح",
"Point the camera at the ground to use the view": "وجّه الكاميرا نحو الأرض لاستخدام العرض",
"That point cannot anchor a box": "لا يمكن تثبيت منطقة على تلك النقطة",
@@ -2848,7 +2844,6 @@
"dB": "dB",
"{time} · {minutes} min ago": "{time} · قبل {minutes} د",
"Active weather": "الطقس النشط",
- "LATEST · newest per product": "الأحدث · آخر إطار لكل منتج",
"Forecast · valid {valid} · issued {issued}": "توقع عند {valid} · صدر {issued}",
"{detail} · Does not follow history": "{detail} · لا يتبع السجل",
"Forecast · valid {valid}": "توقع عند {valid}",
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 9964801bb..9a4738b19 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -2402,12 +2402,8 @@
"PAUSE": "PAUSE",
"RESUME": "FORTSETZEN",
"Clear reading": "Wert ausblenden",
- "Earlier observation": "Frühere Beobachtung",
"Observed history": "Beobachteter Verlauf",
- "Later observation": "Spätere Beobachtung",
"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",
"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",
@@ -2656,7 +2652,6 @@
"dB": "dB",
"{time} · {minutes} min ago": "{time} · vor {minutes} min",
"Active weather": "Aktives Wetter",
- "LATEST · newest per product": "NEUESTE · je Produkt",
"Forecast · valid {valid} · issued {issued}": "Vorhersage · gültig {valid} · ausgegeben {issued}",
"{detail} · Does not follow history": "{detail} · Folgt nicht dem Verlauf",
"Forecast · valid {valid}": "Vorhersage · gültig {valid}",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index cc72601c9..0848335b3 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -1667,12 +1667,8 @@
"CH {index} / {total}": "CANAL {index} / {total}",
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
"Clear reading": "Borrar lectura",
- "Earlier observation": "Observación anterior",
"Observed history": "Historial observado",
- "Later observation": "Observación posterior",
"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",
"← → preview · S shows the focused day": "← → vista previa · S muestra el día enfocado",
"Recent imagery": "Imágenes recientes",
@@ -1836,7 +1832,6 @@
"Reset map bearing to north": "Restablecer la orientación del mapa al norte",
"Rain radar": "Radar de lluvia",
"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}",
"{detail} · Does not follow history": "{detail} · No sigue el historial",
"Forecast · valid {valid}": "Pronóstico · válido {valid}",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 0e417867e..03ae2354e 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -2150,12 +2150,8 @@
"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",
"Clear reading": "Effacer le relevé",
- "Earlier observation": "Observation précédente",
"Observed history": "Historique des observations",
- "Later observation": "Observation suivante",
"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",
"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",
@@ -2446,7 +2442,6 @@
"DETECT": "DÉTECT.",
"{time} · {minutes} min ago": "{time} · il y a {minutes} min",
"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}",
"{detail} · Does not follow history": "{detail} · Ne suit pas l'historique",
"Forecast · valid {valid}": "Prévision · valide {valid}",
diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json
index 16dbedb45..5d76d6c4f 100644
--- a/src/i18n/locales/he.json
+++ b/src/i18n/locales/he.json
@@ -1996,11 +1996,7 @@
"READY": "מוכן",
"Clear reading": "ניקוי הקריאה",
"Observed history": "היסטוריה נצפית",
- "Earlier observation": "תצפית קודמת",
- "Later observation": "תצפית מאוחרת",
- "Newest frame per product": "השקופית החדשה לכל מוצר",
"Latest": "האחרונה",
- "Replay observed history": "הצגת ההיסטוריה הנצפית שוב",
"Imagery on Esri · Google 3D returns when cleared": "צילומים ב-Esri · Google 3D חוזר כשמנקים",
"Point the camera at the ground to use the view": "כוון את המצלמה אל הקרקע כדי להשתמש בתצוגה",
"That point cannot anchor a box": "אין אפשרות לעגן תיבה באותה נקודה",
@@ -2783,7 +2779,6 @@
"Detection overlay: off": "שכבת הזיהוי: כבויה",
"Active weather": "מזג אוויר פעיל",
"{time} · {minutes} min ago": "{time} · לפני {minutes} דק׳",
- "LATEST · newest per product": "האחרון · החדש ביותר לכל מוצר",
"Forecast · valid {valid} · issued {issued}": "תחזית · תקפה {valid} · פורסמה {issued}",
"{detail} · Does not follow history": "{detail} · אינו נע עם ההיסטוריה",
"Forecast · valid {valid}": "תחזית · תקפה {valid}",
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index e3fe590e5..65fd4f5fe 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -2150,12 +2150,8 @@
"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",
"Clear reading": "Cancella la lettura",
- "Earlier observation": "Osservazione precedente",
"Observed history": "Storico osservato",
- "Later observation": "Osservazione successiva",
"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",
"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",
@@ -2446,7 +2442,6 @@
"DETECT": "RILEV.",
"{time} · {minutes} min ago": "{time} · {minutes} min fa",
"Active weather": "Meteo attivo",
- "LATEST · newest per product": "ULTIMO · il più recente per prodotto",
"Forecast · valid {valid} · issued {issued}": "Previsione · valida {valid} · emessa {issued}",
"{detail} · Does not follow history": "{detail} · Non segue lo storico",
"Forecast · valid {valid}": "Previsione · valida {valid}",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 0a7d60ba9..e50ef01ca 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -2076,12 +2076,8 @@
"Radio is disabling…": "ラジオを無効化しています…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "ラジオのライフサイクルが不明確です。有効化または無効化で再調整してください",
"Clear reading": "読み取り値を消去",
- "Earlier observation": "前の観測",
"Observed history": "観測履歴",
- "Later observation": "次の観測",
"Latest": "最新",
- "Newest frame per product": "プロダクトごとの最新フレーム",
- "Replay observed history": "観測履歴を再生",
"Imagery on Esri · Google 3D returns when cleared": "画像はEsri上に表示 · 消去するとGoogle 3Dに戻ります",
"Point the camera at the ground to use the view": "このビューを使うにはカメラを地面に向けてください",
"That point cannot anchor a box": "その地点は範囲の基点にできません",
@@ -2346,7 +2342,6 @@
"DETECT": "検出",
"{time} · {minutes} min ago": "{time} · {minutes}分前",
"Active weather": "有効な気象レイヤー",
- "LATEST · newest per product": "最新 · プロダクトごとの最新",
"Forecast · valid {valid} · issued {issued}": "予報 · 対象時刻 {valid} · 発表 {issued}",
"{detail} · Does not follow history": "{detail} · 履歴とは連動しません",
"Forecast · valid {valid}": "予報 · 対象時刻 {valid}",
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index 2e3b0d016..7791af632 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -2150,12 +2150,8 @@
"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",
"Clear reading": "Limpar leitura",
- "Earlier observation": "Observação anterior",
"Observed history": "Histórico observado",
- "Later observation": "Observação posterior",
"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",
"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",
@@ -2446,7 +2442,6 @@
"DETECT": "DETECÇÃO",
"{time} · {minutes} min ago": "{time} · há {minutes} min",
"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}",
"{detail} · Does not follow history": "{detail} · Não acompanha o histórico",
"Forecast · valid {valid}": "Previsão · válida para {valid}",
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index 5553d847e..7d4ab5612 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -1697,12 +1697,8 @@
"CH {index} / {total}": "КАН. {index} / {total}",
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
"Clear reading": "Очистить показания",
- "Earlier observation": "Более раннее наблюдение",
"Observed history": "История наблюдений",
- "Later observation": "Более позднее наблюдение",
"Latest": "Последние",
- "Newest frame per product": "Самый свежий кадр каждого продукта",
- "Replay observed history": "Воспроизвести историю наблюдений",
"S on another day replaces it · × unpins": "S в другой день заменяет его · × открепляет",
"← → preview · S shows the focused day": "← → просмотр · S показывает выбранный день",
"Recent imagery": "Свежие снимки",
@@ -1867,7 +1863,6 @@
"Reset map bearing to north": "Сбросить азимут карты на север",
"Rain radar": "Радар осадков",
"Lightning density": "Плотность молний",
- "LATEST · newest per product": "ПОСЛЕДНИЕ · самые свежие по каждому продукту",
"Forecast · valid {valid} · issued {issued}": "Прогноз · действует на {valid} · выпущен {issued}",
"{detail} · Does not follow history": "{detail} · не следует за историей",
"Forecast · valid {valid}": "Прогноз · действует на {valid}",
diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json
index 05f004f07..46d671561 100644
--- a/src/i18n/locales/sv.json
+++ b/src/i18n/locales/sv.json
@@ -2324,12 +2324,8 @@
"PAUSE": "PAUSA",
"RESUME": "FORTSÄTT",
"Clear reading": "Rensa avläsningen",
- "Earlier observation": "Tidigare observation",
"Observed history": "Observerad historik",
- "Later observation": "Senare observation",
"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",
"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",
@@ -2578,7 +2574,6 @@
"dB": "dB",
"{time} · {minutes} min ago": "{time} · för {minutes} min sedan",
"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}",
"{detail} · Does not follow history": "{detail} · Följer inte historiken",
"Forecast · valid {valid}": "Prognos · gäller {valid}",
diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json
index d841eb0b9..7456299a3 100644
--- a/src/i18n/locales/uk.json
+++ b/src/i18n/locales/uk.json
@@ -2186,12 +2186,8 @@
"Radio is disabling…": "Радіо вимикається…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Стан радіо невизначений — звірте через «Увімкнути» або «Вимкнути»",
"Clear reading": "Очистити покази",
- "Earlier observation": "Попереднє спостереження",
"Observed history": "Історія спостережень",
- "Later observation": "Наступне спостереження",
"Latest": "Останні",
- "Newest frame per product": "Найсвіжіший кадр кожного продукту",
- "Replay observed history": "Відтворити історію спостережень",
"Imagery on Esri · Google 3D returns when cleared": "Підкладка: Esri · скидання поверне Google 3D",
"Point the camera at the ground to use the view": "Наведіть камеру на землю, щоб вибрати видиму область",
"That point cannot anchor a box": "Від цієї точки не можна побудувати область",
@@ -2495,7 +2491,6 @@
"DETECT": "ВИЯВЛЕННЯ",
"{time} · {minutes} min ago": "{time} · {minutes} хв тому",
"Active weather": "Активні погодні шари",
- "LATEST · newest per product": "ОСТАННІ · найсвіжіші для кожного продукту",
"Forecast · valid {valid} · issued {issued}": "Прогноз · чинний на {valid} · випущено {issued}",
"{detail} · Does not follow history": "{detail} · не синхронізується з історією",
"Forecast · valid {valid}": "Прогноз · чинний на {valid}",
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 26109d574..79d11512b 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -2046,12 +2046,8 @@
"Radio is disabling…": "电台正在停用…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "电台生命周期状态不确定——请使用“启用”或“停用”进行核对",
"Clear reading": "清除读数",
- "Earlier observation": "较早观测",
"Observed history": "观测历史",
- "Later observation": "较晚观测",
"Latest": "最新",
- "Newest frame per product": "每个产品的最新一帧",
- "Replay observed history": "回放观测历史",
"Imagery on Esri · Google 3D returns when cleared": "影像显示在 Esri 底图上 · 清除后恢复 Google 3D",
"Point the camera at the ground to use the view": "将相机对准地面才能使用当前视野",
"That point cannot anchor a box": "该点无法作为框选锚点",
@@ -2310,7 +2306,6 @@
"DETECT": "探测",
"{time} · {minutes} min ago": "{time} · {minutes} 分钟前",
"Active weather": "已启用的天气图层",
- "LATEST · newest per product": "最新 · 每个产品的最新一帧",
"Forecast · valid {valid} · issued {issued}": "预报 · 有效时间 {valid} · 发布时间 {issued}",
"{detail} · Does not follow history": "{detail} · 不随观测历史变化",
"Forecast · valid {valid}": "预报 · 有效时间 {valid}",
diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json
index f119fd487..5135b13ce 100644
--- a/src/i18n/locales/zh-Hant.json
+++ b/src/i18n/locales/zh-Hant.json
@@ -2036,12 +2036,8 @@
"Radio is disabling…": "電臺正在停用…",
"Radio lifecycle is uncertain — use Enable or Disable to reconcile": "電臺生命週期狀態不確定——請使用“啟用”或“停用”進行核對",
"Clear reading": "清除讀數",
- "Earlier observation": "較早觀測",
"Observed history": "觀測歷史",
- "Later observation": "較晚觀測",
"Latest": "最新",
- "Newest frame per product": "每個產品的最新一幀",
- "Replay observed history": "回放觀測歷史",
"Imagery on Esri · Google 3D returns when cleared": "影像顯示在 Esri 底圖上 · 清除後恢復 Google 3D",
"Point the camera at the ground to use the view": "將相機對準地面才能使用當前視野",
"That point cannot anchor a box": "該點無法作為框選錨點",
@@ -2300,7 +2296,6 @@
"DETECT": "探測",
"{time} · {minutes} min ago": "{time} · {minutes} 分鐘前",
"Active weather": "已啟用的天氣圖層",
- "LATEST · newest per product": "最新 · 每個產品的最新一幀",
"Forecast · valid {valid} · issued {issued}": "預報 · 有效時間 {valid} · 發布時間 {issued}",
"{detail} · Does not follow history": "{detail} · 不隨觀測歷史變化",
"Forecast · valid {valid}": "預報 · 有效時間 {valid}",
diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js
index 8e539473e..672415973 100644
--- a/src/layers/weather/clock.js
+++ b/src/layers/weather/clock.js
@@ -59,12 +59,15 @@ export const PLAY_WAIT_MS = 8_000;
// costs play nothing.
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
* 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
- * moves the world clock as it goes, as the world clock's driver.
+ * history at the world's time otherwise. It never moves the world clock
+ * 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({
now = Date.now,
@@ -76,17 +79,10 @@ export function createWeatherClock({
const listeners = new Set();
let mode = 'latest';
let target = null;
- let playing = false;
- // The world time last applied; while this clock moves the world, the
- // transition its move started.
+ // The world time last applied.
let seen = 'latest';
- let moving = false;
- let moved = null;
const releases = new Map();
- // Attached at the end, once the API exists.
- let driver = null;
let generation = 0;
- let timer = null;
let pending = null;
// This clock's name on the world, for its holds and its gate.
const holdId = `weather:${++clocks}`;
@@ -147,10 +143,9 @@ export function createWeatherClock({
const getState = () => ({
mode,
target,
- playing,
- // Frames come on one after another: this clock replays them, or the world
- // plays on through them, so the next is worth loading ahead.
- advancing: playing || worldPlaysOn(),
+ // Frames come on one after another as the world plays on through them,
+ // so the next is worth loading ahead.
+ advancing: worldPlaysOn(),
timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({
id: product.id,
@@ -162,60 +157,12 @@ export function createWeatherClock({
const notify = () => {
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 = () =>
[...registry.values()].map(({ product }) => selectedFor(product)).join();
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() {
if (destroyed) return Promise.resolve(false);
- cancelTimer();
const owner = ++generation;
- if (!canPlay()) stopPlaying();
// A scrub drops what loads and moves on; play goes on as this loads.
applied = selections();
deferred = false;
@@ -235,15 +182,12 @@ export function createWeatherClock({
if (destroyed || owner !== generation) return false;
stopWaiting();
pending = null;
- if (!canPlay()) stopPlaying();
notify();
- schedule();
// Play held at the next frame for this one goes on to it.
if (deferred) void transition();
return true;
});
pending = result;
- if (moving) moved ??= result;
notify();
return result;
}
@@ -294,81 +238,17 @@ export function createWeatherClock({
},
getTimeline,
selectFor,
- setTarget(time) {
- if (destroyed) return Promise.resolve(false);
- if (!Number.isFinite(Date.parse(time)))
- throw new TypeError('Weather target must be a UTC time');
- const normalized = new Date(time).toISOString();
- 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();
- },
+ /**
+ * The world clock this clock follows. Time moves there, through the same
+ * seek, play and live the world's timeline uses, never through this clock.
+ */
+ world,
refresh() {
if (destroyed) return Promise.resolve(false);
// New frames, or products added or dropped, and with them the next.
world.coverageChanged();
gated = undefined;
gateNext(world.getState());
- if (!canPlay()) {
- stopPlaying();
- cancelTimer();
- }
if (mode === 'history') return transition();
notify();
return Promise.resolve(true);
@@ -380,15 +260,15 @@ export function createWeatherClock({
},
destroy() {
if (destroyed) return;
- stopPlaying();
destroyed = true;
++generation;
stopWaiting();
world.gate(holdId, null);
- cancelTimer();
unfollowWorld();
// 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();
releases.clear();
for (const entry of registry.values()) entry.controller?.abort();
@@ -397,9 +277,8 @@ export function createWeatherClock({
pending = null;
},
};
- // Follow the world clock. A move of this clock's own always applies; one
- // from elsewhere (a host's seek, continuous play) applies only when it
- // changes the mode or a product's frame.
+ // Follow the world clock. A move (a seek, play going on) applies only when
+ // it changes the mode or a product's frame.
function followWorld(state) {
if (destroyed) return;
gateNext(state);
@@ -420,7 +299,7 @@ export function createWeatherClock({
const modeChanged = nextMode !== mode;
mode = nextMode;
target = nextTarget;
- if (!moving && !modeChanged && selections() === applied) {
+ if (!modeChanged && selections() === applied) {
// Back at the frame that loads, play need not hold for it.
if (deferred) {
deferred = false;
@@ -433,7 +312,7 @@ export function createWeatherClock({
}
// Playing on into the next frame while the one before it loads, the
// world holds here until that one shows.
- if (!moving && !modeChanged && pending && playsOn) {
+ if (!modeChanged && pending && playsOn) {
deferred = true;
startWaiting();
notify();
@@ -456,10 +335,7 @@ export function createWeatherClock({
world.gate(holdId, next);
}
const unfollowWorld = world.subscribe(followWorld);
- driver = world.attachDriver({
- play: () => (canPlay() ? void api.play() : false),
- pause: () => api.pause(),
- });
+ followers.set(world, (followers.get(world) ?? 0) + 1);
followWorld(world.getState());
return api;
}
diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs
index 90cd1bd6b..5a5c36957 100644
--- a/src/layers/weather/clock.test.mjs
+++ b/src/layers/weather/clock.test.mjs
@@ -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);
});
-test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => {
- const { clock } = harness(t);
+const at = (minutes) => Date.parse(time(minutes));
+
+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 satellite = product('satellite', [5, 15]);
const lightning = product('lightning', [15]);
for (const p of [radar, satellite, lightning]) clock.register(p);
- await clock.setTarget(time(10));
+ world.seek(at(10));
+ await flush();
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[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),
[time(10), time(5), null],
);
- await clock.latest();
+ world.goLive();
+ await flush();
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[time(20), time(15), time(15)],
);
assert.equal(clock.getState().mode, 'latest');
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) => {
- const { clock, timers } = harness(t);
+test('superseded completions cannot commit or notify', async (t) => {
+ const { clock, world, timers } = harness(t);
const p = product('radar', [0, 10, 20]);
const loads = [];
p.apply = (selected, { signal }) =>
@@ -201,113 +205,44 @@ test('superseded completions cannot commit, notify or schedule a new playback ad
clock.register(p);
let notifications = 0;
clock.subscribe(() => notifications++);
- const old = clock.setTarget(time(0));
+ world.seek(at(0));
await flush();
- const current = clock.setTarget(time(10));
+ world.seek(at(10));
await flush();
assert.equal(loads[0].signal.aborted, true);
loads[1].finish();
- assert.equal(await current, true);
+ await flush();
const count = notifications;
loads[0].finish();
- assert.equal(await old, false);
+ await flush();
assert.equal(notifications, count);
assert.equal(p.shown, time(10));
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
});
-test('steps use strict union neighbours from arbitrary targets and clamp at endpoints', async (t) => {
- const { clock } = harness(t);
- clock.register(product('radar', [0, 10, 20]));
- clock.register(product('satellite', [5, 15]));
- await clock.step(-1);
- assert.equal(clock.getState().target, time(15));
- await clock.setTarget(time(12));
- await clock.step(-1);
- assert.equal(clock.getState().target, time(10));
- await clock.setTarget(time(12));
- await clock.step(1);
- assert.equal(clock.getState().target, time(15));
- 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('the clock has no time controls of its own; the world clock moves time', (t) => {
+ const { clock, world } = harness(t);
+ for (const name of [
+ 'setTarget',
+ 'step',
+ 'latest',
+ 'play',
+ 'pause',
+ 'togglePlay',
+ ])
+ assert.equal(clock[name], undefined, name);
+ assert.equal(clock.world, world);
});
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]);
clock.register(p);
await clock.refresh();
assert.equal(p.calls.length, 0);
- await clock.setTarget(time(10));
+ world.seek(at(10));
+ await flush();
p.times = [time(0), time(20)];
await clock.refresh();
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) => {
- const { clock, timers, tick } = harness(t);
+ const { clock, world, timers } = harness(t);
const p = product('radar', [0, 10]);
const unregister = clock.register(p);
- await clock.setTarget(time(0));
+ world.seek(at(0));
+ await flush();
const b = product('satellite', [0, 5]);
clock.register(b);
await flush();
assert.equal(b.shown, time(0));
- await clock.play();
+ world.seek(at(5));
unregister();
unregister();
await flush();
@@ -337,31 +273,27 @@ test('registration joins history; unregister and destroy abort work and release
);
let notifications = 0;
const off = clock.subscribe(() => notifications++);
- clock.pause();
off();
- const before = notifications;
- await clock.play();
- assert.equal(notifications, before);
+ world.seek(at(0));
+ await flush();
+ assert.equal(notifications, 0);
+ world.seek(at(5));
clock.destroy();
clock.destroy();
- tick(2000);
+ await flush();
assert.equal(timers.size, 0);
assert.equal(b.calls.at(-1).signal.aborted, true);
- assert.equal(clock.getState().playing, false);
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 radar = product('radar', [0, 10, 20]);
clock.register(radar);
assert.equal(world.follows('radar'), true);
- await clock.setTarget(time(10));
- assert.equal(world.isLive(), false);
- assert.equal(world.now(), Date.parse(time(10)));
- world.seek(Date.parse(time(3)));
+ world.seek(at(3));
await flush();
+ assert.equal(clock.getState().mode, 'history');
assert.equal(clock.getState().target, time(3));
assert.equal(radar.shown, time(0));
world.goLive();
@@ -370,30 +302,22 @@ test('the weather time is the world time, both ways', async (t) => {
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 radar = product('radar', [0, 10, 20]);
clock.register(radar);
- world.seek(Date.parse(time(0)));
+ world.seek(at(0));
await flush();
- world.play();
- assert.equal(clock.getState().playing, true);
- assert.equal(world.isPlaying(), true);
- tick(2000);
+ world.play({ rate: 60 });
+ assert.equal(world.getState().stepping, false);
+ assert.equal(clock.getState().advancing, true);
+ tick(5_000);
+ assert.equal(world.now(), at(5), 'five minutes on at a minute a second');
+ tick(10_000);
await flush();
- assert.equal(radar.shown, time(10));
- assert.equal(world.now(), Date.parse(time(10)));
+ assert.equal(world.now(), at(10), 'held at the next frame');
world.pause();
- assert.equal(clock.getState().playing, 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');
+ assert.equal(clock.getState().advancing, false);
});
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));
});
-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) {
return createWorldClock({
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 a = createWeatherClock({ world, now: () => Date.parse(time(20)) });
const b = createWeatherClock({ world, now: () => Date.parse(time(20)) });
- const radarA = product('radar', [0, 10, 20]);
- const radarB = 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);
+ a.register(product('radar', [0, 10, 20]));
+ b.register(product('radar', [0, 10, 20]));
world.seek(Date.parse(time(0)));
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();
assert.equal(world.follows('radar'), false);
assert.equal(world.isLive(), true);
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) => {
const { clock, world } = harness(t);
assert.equal(world.coverage(), null);
diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js
index 55593a437..9f5761ccc 100644
--- a/src/layers/weather/index.js
+++ b/src/layers/weather/index.js
@@ -53,6 +53,37 @@ const utc = (value) =>
? `${formatUtcDayMonth(value)} ${value.slice(11, 16)} UTC`
: 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
* 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
@@ -254,7 +285,10 @@ export function createWeatherLayer({
)
return;
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;
try {
await rendering.prefetch?.(manifest, next, { infrared });
@@ -528,11 +562,9 @@ export function createWeatherLayer({
}),
);
}
- if (clock && enabled) {
- if (params.play === true) void clock.togglePlay();
- if (params.latest === true) void clock.latest();
- if ([-1, 1].includes(params.step)) void clock.step(params.step);
- }
+ // With a clock, time is the world's: these move the world clock as its
+ // timeline does, and every layer that follows it moves along.
+ if (clock && enabled) moveWorldTime(clock.world, params);
if (
!clock &&
params.play === true &&
@@ -897,14 +929,14 @@ export function createWeatherLayer({
const shared = clock?.getState();
return {
...rendering?.getDiagnostics(),
- playing: shared ? shared.playing : playing && !hostHidden,
+ playing: shared ? shared.advancing : playing && !hostHidden,
followLatest: isLatest(),
...(shared
? {
clock: {
mode: shared.mode,
target: shared.target,
- playing: shared.playing,
+ playing: shared.advancing,
},
}
: {}),
diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs
index e1ba553e4..78f2875bd 100644
--- a/src/layers/weather/weather.test.mjs
+++ b/src/layers/weather/weather.test.mjs
@@ -35,6 +35,16 @@ const deferred = () => {
const flush = async () => {
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() {
const listeners = new Set();
return {
@@ -1765,7 +1775,7 @@ test('a delayed installation failure retains the replacement and does not throw
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 radar = layerHarness({ clock });
const satellite = layerHarness({
@@ -1798,6 +1808,14 @@ test('one row steps every registered observation; missing frames hide and latest
.chips.filter(({ 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 });
await flush();
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);
});
+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) => {
const clock = createWeatherClock();
let value = snapshot;
@@ -1861,7 +1906,7 @@ test('history manifest expiry hides imagery without jumping to latest; host resu
await flush();
h.stages.at(-1).finish();
await update;
- const history = clock.setTarget(times[0]);
+ const history = seekTo(clock, times[0]);
await flush();
h.stages.at(-1).finish();
await history;
@@ -1930,7 +1975,7 @@ test('satellite global selection uses the three-hour gap and preserves the share
await flush();
h.stages.at(-1).finish();
await update;
- await clock.setTarget(times[0]);
+ await seekTo(clock, times[0]);
assert.equal(
h.layer.getDiagnostics().hidden,
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.
- const between = clock.setTarget(
+ const between = seekTo(
+ clock,
new Date(Date.parse(hourly[0]) + 45 * 60_000).toISOString(),
);
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();
await flush();
const latest = h.stages.at(-1);
- const oldHistory = clock.setTarget(times[0]);
+ const oldHistory = seekTo(clock, times[0]);
await flush();
assert.equal(latest.signal.aborted, true);
const oldStage = h.stages.at(-1);
- const current = clock.setTarget(times[1]);
+ const current = seekTo(clock, times[1]);
await flush();
assert.equal(oldStage.signal.aborted, true);
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);
});
-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 { world } = clock;
const h = layerHarness({ clock });
const warmed = [];
let cancels = 0;
@@ -2349,45 +2396,45 @@ test('layer warms the next advertised observation only after a successful playin
await flush();
h.stages.at(-1).finish();
await updated;
- assert.equal(warmed.length, 0);
- const play = clock.play();
+ await seekTo(clock, times[0]);
+ h.stages.at(-1).finish();
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');
h.stages.at(-1).finish();
- await play;
+ await flush();
assert.deepEqual(
warmed.map(({ time }) => time),
- [times[0]],
+ [times[2]],
);
assert.deepEqual(warmed[0].options, { infrared: 100 });
- const next = clock.setTarget(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();
+ await seekTo(clock, times[0]);
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();
+ assert.equal(warmed.length, 1, 'a failed frame warms nothing');
+ await seekTo(clock, times[2]);
h.stages.at(-1).finish();
- await paused;
- assert.equal(warmed.length, 2);
- await clock.play();
+ await flush();
+ assert.equal(warmed.length, 1, 'the world plays on past the newest frame');
+ 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.emit('visibilitychange');
await flush();
- h.stages.at(-1).finish();
+ h.stages.at(-1)?.finish();
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']) {
diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs
index c0b738643..134724543 100644
--- a/src/layers/wind/index.test.mjs
+++ b/src/layers/wind/index.test.mjs
@@ -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 () => {
const { createWeatherClock } = await import('../weather/clock.js');
const clock = createWeatherClock();
+ const { world } = clock;
const layer = createWindLayer({ feed: { getSnapshot: async () => snapshot('gfs') }, clock });
let changes = 0;
layer.setRowControlsListener(() => changes++);
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.ok(changes > 0);
assert.match(layer.getRowControls().info, /Forecast · does not follow history/);
assert.deepEqual(layer.getParams(), params);
- await clock.latest();
+ world.goLive();
+ await Promise.resolve();
assert.equal(layer.getRowControls().summary.status, null);
layer.destroy();
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);
clock.destroy();
});
diff --git a/src/time/worldClock.js b/src/time/worldClock.js
index ad83f6a41..c600c7b3f 100644
--- a/src/time/worldClock.js
+++ b/src/time/worldClock.js
@@ -14,9 +14,10 @@
* src/time/snapshotTimeline.js); otherwise they stay live.
*
* Playback is continuous (`cursor + elapsed × rate`) unless a driver takes
- * it: weather replay steps its observed frames itself and moves the clock as
- * it goes, so play and pause hand over to it. Several drivers may be attached;
- * play goes to the newest that can play, pause to the one playing.
+ * it: a driver steps the clock itself and moves it as it goes, so play and
+ * pause hand over to it. Several drivers may be attached; play goes to the
+ * 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. */
diff --git a/src/ui/rail/RailTimeline.vue b/src/ui/rail/RailTimeline.vue
deleted file mode 100644
index 7b4153531..000000000
--- a/src/ui/rail/RailTimeline.vue
+++ /dev/null
@@ -1,178 +0,0 @@
-
-
-
-