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 @@ - - - diff --git a/src/ui/railTimeline.js b/src/ui/railTimeline.js deleted file mode 100644 index b6dcd178e..000000000 --- a/src/ui/railTimeline.js +++ /dev/null @@ -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); - }, - }; -} diff --git a/src/ui/railTimeline.test.mjs b/src/ui/railTimeline.test.mjs deleted file mode 100644 index 9e408bdf3..000000000 --- a/src/ui/railTimeline.test.mjs +++ /dev/null @@ -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(); -}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 9dcb7d4c8..72b4738dc 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -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 * 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 - * frame by frame like every other layer the track covers. + * the clock itself: weather follows the time frame by frame like every + * other layer the track covers. */ togglePlay() { const { playing, live, timeMs, span } = time.value; @@ -138,9 +138,9 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { }, speed, /** - * Choose how fast play runs; playing, it changes at once. Weather's own - * replay steps at its own pace, so a speed chosen while it plays takes - * playback over from where it is. + * Choose how fast play runs; playing, it changes at once. A driver steps + * at its own pace, so a speed chosen while one plays takes playback over + * from where it is. */ setSpeed(next) { if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return; diff --git a/src/ui/styles/weather.css b/src/ui/styles/weather.css index 8fb92e37c..d0cf13a25 100644 --- a/src/ui/styles/weather.css +++ b/src/ui/styles/weather.css @@ -56,8 +56,7 @@ } /* Card typography follows the layer rows and app tokens. */ .weather-readout, -.rail-card, -.rail-timeline { +.rail-card { min-width: 0; color: var(--text-dim); font: 9px/1.45 var(--font-mono); @@ -128,7 +127,7 @@ text-align: right; } .rail-card-line, -.rail-timeline-readout { +.weather-frame-time { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.5px; @@ -188,54 +187,13 @@ html:not([lang='en']) .weather-card [data-line-id='status'] { letter-spacing: 0.5px; } .rail-card [hidden], -.weather-timeline-block[hidden], +.weather-frame-time[hidden], .weather-observed-group[hidden] { display: none !important; } -.rail-timeline { +.weather-frame-time { padding: 5px; 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; font-variant-numeric: tabular-nums; color: var(--text-secondary); diff --git a/src/ui/weather/WeatherReadout.vue b/src/ui/weather/WeatherReadout.vue index c7c341816..b89e6ed86 100644 --- a/src/ui/weather/WeatherReadout.vue +++ b/src/ui/weather/WeatherReadout.vue @@ -1,15 +1,14 @@ @@ -31,16 +30,9 @@ defineProps({ :aria-label="t('Observed history')" >

{{ t('Observed history') }}

{{ view.scope }}
{{ + view.frameTime + }}
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. const paint = (view) => @@ -103,14 +96,7 @@ export function createWeatherPanel({ hidden: true, observed: false, scope: '', - timeline: { - ticks: [], - index: 0, - mode: 'latest', - playing: false, - readout: '', - disabled: true, - }, + frameTime: '', cards: [], observedCards: [], }); @@ -125,32 +111,22 @@ export function createWeatherPanel({ .filter(({ summary }) => summary) .sort((a, b) => ORDER.indexOf(a.id) - ORDER.indexOf(b.id)); const observed = active.filter(({ id }) => OBSERVED.has(id)); - const showTimeline = observed.length > 0; + const showObserved = observed.length > 0; const names = { 'weather-radar': t('Rain radar'), 'weather-satellite': t('Satellite clouds'), 'weather-lightning': t('Lightning density'), }; - const index = - state.mode === 'latest' - ? state.timeline.length - 1 - : Math.max( - 0, - state.timeline.findLastIndex( - (tick) => Date.parse(tick) <= Date.parse(state.target), - ), - ); - 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), - }; + // While live, the newest frame and its age; at a past time, the timeline + // time alone. The world timeline moves the time. + const shownTime = + state.mode === 'latest' ? state.timeline.at(-1) : state.target; + const frameTime = + showObserved && shownTime + ? state.mode === 'history' + ? clockTime(shownTime) + : historyTime(shownTime) + : ''; const models = active.map((entry) => { const { id, icon, summary, legend = [], list } = entry; // Discrete classes (storm geometry, AQHI bands) list swatches, not a ramp. @@ -234,9 +210,9 @@ export function createWeatherPanel({ const hidden = active.length === 0; paint({ hidden, - observed: showTimeline, + observed: showObserved, scope: observed.map(({ id }) => names[id]).join(' · '), - timeline, + frameTime, cards: models.filter(({ id }) => !OBSERVED.has(id)), observedCards: models.filter(({ id }) => OBSERVED.has(id)), }); diff --git a/src/ui/weatherPanel.test.mjs b/src/ui/weatherPanel.test.mjs index d3e3d30cb..c8c032741 100644 --- a/src/ui/weatherPanel.test.mjs +++ b/src/ui/weatherPanel.test.mjs @@ -54,10 +54,6 @@ function fixture() { listeners.add(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 { ...f, @@ -74,39 +70,36 @@ function fixture() { }, }; } -const timelineHost = (f) => - f.find((n) => n.className === 'weather-timeline-block'); +const frameTime = (f) => f.find((n) => n.className === 'weather-frame-time'); const card = (f, id) => f.find((n) => n.dataset.cardId === id); const line = (f, id, name) => 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) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); +test('the frame time shows with any observed product; the panel has no time controls of its own', (t) => { + t.mock.timers.enable({ + apis: ['Date'], + now: Date.parse('2026-09-22T01:17:00Z'), + }); const f = fixture(); const view = createWeatherPanel(f); view.update([wind]); - assert.equal(timelineHost(f).hidden, true); + assert.equal(frameTime(f).hidden, true); 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]] }); - assert.equal(timelineHost(f).hidden, false); - assert.equal(f.find((n) => n.tagName === 'INPUT').disabled, true); + assert.equal(frameTime(f).textContent, '01:00 UTC · 17 min ago'); f.state({ timeline: [] }); - assert.equal(timelineHost(f).hidden, false); - f.state({ timeline: ticks }); - assert.equal(timelineHost(f).hidden, false); - const slider = f.find((n) => n.tagName === 'INPUT'); - slider.value = '0'; - slider.dispatchEvent(new Event('input')); - assert.deepEqual(f.calls, []); - slider.dispatchEvent(new Event('change')); - assert.deepEqual(f.calls, [['target', ticks[0]]]); - f.find((n) => n.textContent === 'Latest' && n.tagName === 'BUTTON').click(); - 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'); + assert.equal(frameTime(f).hidden, true); + f.state({ timeline: ticks, mode: 'history', target: ticks[0] }); + assert.equal(frameTime(f).textContent, '01:00 UTC'); + assert.equal( + f.find((n) => n.tagName === 'INPUT'), + null, + ); + assert.equal( + f.find((n) => ['Play', 'Latest'].includes(n.textContent)), + null, + ); view.destroy(); assert.equal(f.listeners.size, 0); }); @@ -179,7 +172,7 @@ test('cyclone readout retains advisory, position, intensity, geometry, coverage, ], }; view.update([cyclone]); - assert.equal(timelineHost(f).hidden, true); + assert.equal(frameTime(f).hidden, true); assert.match( line(f, cyclone.id, 'time').textContent, /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, 'Rain radar · Satellite clouds · Lightning density', ); - assertSameNode(group.children[2], timelineHost(f)); + assertSameNode(group.children[2], frameTime(f)); assert.deepEqual( [...group.children[3].children].map((n) => n.dataset.cardId), ['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 view = createWeatherPanel(f); 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] }); + assert.equal(frameTime(f).textContent, '01:05 UTC', locale); assert.match( line(f, radar.id, 'time').textContent, /(^|[^\d:])01:05 UTC/,