From 3cfd414e703b44555ecbc45e71c8859af42fe261 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 20:25:31 -0800 Subject: [PATCH] The main timeline is the only time control; the Weather panel shows its frame time The Weather panel kept a second time transport of its own: a slider, step buttons, Latest and Play, backed by a replay in the weather clock that stepped the observed frames every two seconds and went round from the last to the first. The weather clock also attached itself to the world clock as a driver, so the timeline's Play became that replay whenever weather was on. The panel now shows the frame time only: the newest frame and its age while live, the timeline time alone at a past time. The weather clock no longer has setTarget, step, latest, play, pause or togglePlay, its state no longer has playing, and it no longer drives the world clock. It still follows the world time, registers each product's frames as marks the timeline's arrow keys step to, holds its products, and gates the world's continuous play at each next frame until it loads. weatherClock.world names the world clock it follows. A weather row's play, latest and step params move the world clock as the timeline does: play toggles continuous play (from the start of the covered time when live), latest goes live, and step goes to the previous or next mark. The rail timeline component, which only the panel used, is removed, along with its unused messages in every locale. --- .../0000-timeline-one-control-and-marks.md | 14 + docs/APPLICATION.md | 5 +- docs/CODE-BOUNDARIES.md | 15 +- docs/CURRENT-STATE.md | 28 +- docs/VUE-MIGRATION.md | 6 +- scripts/format-scope.json | 2 - scripts/package-boundaries.json | 6 - src/app/constructCatalog.test.mjs | 22 +- src/i18n/locales/ar.json | 5 - src/i18n/locales/de.json | 5 - src/i18n/locales/es.json | 5 - src/i18n/locales/fr.json | 5 - src/i18n/locales/he.json | 5 - src/i18n/locales/it.json | 5 - src/i18n/locales/ja.json | 5 - src/i18n/locales/pt.json | 5 - src/i18n/locales/ru.json | 5 - src/i18n/locales/sv.json | 5 - src/i18n/locales/uk.json | 5 - src/i18n/locales/zh-CN.json | 5 - src/i18n/locales/zh-Hant.json | 5 - src/layers/weather/clock.js | 168 ++---------- src/layers/weather/clock.test.mjs | 252 ++++-------------- src/layers/weather/index.js | 48 +++- src/layers/weather/weather.test.mjs | 111 +++++--- src/layers/wind/index.test.mjs | 10 +- src/time/worldClock.js | 7 +- src/ui/rail/RailTimeline.vue | 178 ------------- src/ui/railTimeline.js | 56 ---- src/ui/railTimeline.test.mjs | 178 ------------- src/ui/stores/worldTime.js | 10 +- src/ui/styles/weather.css | 50 +--- src/ui/weather/WeatherReadout.vue | 18 +- src/ui/weatherPanel.js | 52 +--- src/ui/weatherPanel.test.mjs | 60 ++--- 35 files changed, 328 insertions(+), 1033 deletions(-) create mode 100644 changelog.d/0000-timeline-one-control-and-marks.md delete mode 100644 src/ui/rail/RailTimeline.vue delete mode 100644 src/ui/railTimeline.js delete mode 100644 src/ui/railTimeline.test.mjs 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/,