From 3cfd414e703b44555ecbc45e71c8859af42fe261 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 20:25:31 -0800 Subject: [PATCH 1/7] 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/, From 326cab4cf2e21fec0d78197a168e88d90896eaf5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 20:26:26 -0800 Subject: [PATCH 2/7] A weather layer without a shared clock keeps its own on the world clock Built without a weather clock, the weather layer still ran a frame replay of its own: params.play stepped its frames every two seconds and went round from the last to the first, params.step and params.latest picked frames locally, and the layer tracked whether it followed the newest frame. That was a second way to move time. The layer now builds a weather clock on the page's world clock when none is passed, and destroys it with the layer, so every layer follows the world clock and its params move the world clock as the timeline does. The local replay, its timer, the follow-latest flag, the wrap to the first frame, the timerActive diagnostic and the reduced-motion "manual history available" message are removed. --- docs/CURRENT-STATE.md | 4 +- src/i18n/locales/ar.json | 1 - src/i18n/locales/de.json | 1 - src/i18n/locales/es.json | 1 - src/i18n/locales/fr.json | 1 - src/i18n/locales/he.json | 1 - src/i18n/locales/it.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/pt.json | 1 - src/i18n/locales/ru.json | 1 - src/i18n/locales/sv.json | 1 - src/i18n/locales/uk.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-Hant.json | 1 - src/layers/weather/index.js | 209 +++++++--------------------- src/layers/weather/weather.test.mjs | 157 ++++++++++++--------- 16 files changed, 141 insertions(+), 242 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 14a754cb8..26e6a5e3b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -8557,7 +8557,9 @@ 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. +move that world clock as the timeline does. A weather layer built without a shared +clock keeps one of its own on the page's world clock for its lifetime; it has +no frame replay of its own either way. 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 diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index f5d368a45..86b9aef8b 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -2032,7 +2032,6 @@ "60°S–60°N · typically 2–3 h delayed": "60° ج–60° ش · بتأخير 2–3 س عادةً", "North America · infrared imagery": "أمريكا الشمالية · صور بالأشعة تحت الحمراء", "Reduced motion · history playback unavailable": "تقليل الحركة · تشغيل السجل غير متاح", - "Reduced motion · manual history available": "تقليل الحركة · التصفح اليدوي للسجل متاح", "Rain radar · US": "رادار الأمطار · الولايات المتحدة", "Lightning density · 15 min": "كثافة البرق · 15 د", "Satellite clouds": "الغيوم من الأقمار الصناعية", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 9a4738b19..f9d162c01 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1936,7 +1936,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert", "North America · infrared imagery": "Nordamerika · Infrarotbilder", "Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar", - "Reduced motion · manual history available": "Reduzierte Bewegung · manueller Verlauf verfügbar", "Rain radar · US": "Regenradar · USA", "Lightning density · 15 min": "Blitzdichte · 15 min", "Satellite clouds": "Wolken (Satellit)", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0848335b3..8e47a0a4a 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1436,7 +1436,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente con 2–3 h de retraso", "North America · infrared imagery": "Norteamérica · imágenes infrarrojas", "Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible", - "Reduced motion · manual history available": "Movimiento reducido · puedes recorrer el historial a mano", "Rain radar · US": "Radar de lluvia · EE. UU.", "Lightning density · 15 min": "Densidad de rayos · 15 min", "Satellite clouds": "Nubes por satélite", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 03ae2354e..f865c807f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1350,7 +1350,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h", "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge", "Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible", - "Reduced motion · manual history available": "Animations réduites · historique parcourable manuellement", "Rain radar · US": "Radar de pluie · États-Unis", "Lightning density · 15 min": "Densité de foudre · 15 min", "Satellite clouds": "Nuages par satellite", diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index 5d76d6c4f..415b8d743 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -1208,7 +1208,6 @@ "Color: strikes/km²/min ×10³": "צבע: פגיעות ברק/קמ\"ר/דק׳ ×10³", "Color: {units}": "צבע: {units}", "Reduced motion · history playback unavailable": "תנועה מופחתת · הצגת ההיסטוריה אינה זמינה", - "Reduced motion · manual history available": "תנועה מופחתת · היסטוריה ידנית זמינה", "Rain radar · US": "רדאר גשם · ארה\"ב", "Lightning density · 15 min": "צפיפות ברקים · 15 דק׳", "Satellite clouds": "עננים מלוויין", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 65fd4f5fe..8a7190022 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1350,7 +1350,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · di solito 2–3 h di ritardo", "North America · infrared imagery": "Nord America · immagini all'infrarosso", "Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile", - "Reduced motion · manual history available": "Movimento ridotto · storico manuale disponibile", "Rain radar · US": "Radar pioggia · USA", "Lightning density · 15 min": "Densità di fulmini · 15 min", "Satellite clouds": "Nuvole da satellite", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e50ef01ca..d722f8934 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1312,7 +1312,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常2–3時間遅れ", "North America · infrared imagery": "北米 · 赤外画像", "Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可", - "Reduced motion · manual history available": "動きを減らす設定 · 履歴は手動で表示可能", "Rain radar · US": "降水レーダー · 米国", "Lightning density · 15 min": "雷密度 · 15分", "Satellite clouds": "衛星雲画像", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7791af632..82113bb02 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1348,7 +1348,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente com 2–3 h de atraso", "North America · infrared imagery": "América do Norte · imagens infravermelhas", "Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível", - "Reduced motion · manual history available": "Movimento reduzido · é possível percorrer o histórico manualmente", "Rain radar · US": "Radar de chuva · EUA", "Lightning density · 15 min": "Densidade de raios · 15 min", "Satellite clouds": "Nuvens por satélite", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7d4ab5612..aad80ad25 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1458,7 +1458,6 @@ "60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч", "North America · infrared imagery": "Северная Америка · ИК-снимки", "Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно", - "Reduced motion · manual history available": "Уменьшение движения · историю можно листать вручную", "Rain radar · US": "Радар осадков · США", "Lightning density · 15 min": "Плотность молний · 15 мин", "Satellite clouds": "Спутниковая облачность", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 46d671561..9dbf46a7a 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -1858,7 +1858,6 @@ "60°S–60°N · typically 2–3 h delayed": "60° S–60° N · oftast 2–3 h fördröjning", "North America · infrared imagery": "Nordamerika · infraröda bilder", "Reduced motion · history playback unavailable": "Minskad rörelse · uppspelning av historik otillgänglig", - "Reduced motion · manual history available": "Minskad rörelse · manuell historik tillgänglig", "Rain radar · US": "Regnradar · USA", "Lightning density · 15 min": "Blixttäthet · 15 min", "Satellite clouds": "Satellitmoln", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7456299a3..6d341b93e 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1368,7 +1368,6 @@ "60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год", "North America · infrared imagery": "Північна Америка · ІЧ-знімки", "Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне", - "Reduced motion · manual history available": "Зменшення руху · історію можна гортати вручну", "Rain radar · US": "Радар опадів · США", "Lightning density · 15 min": "Щільність блискавок · 15 хв", "Satellite clouds": "Супутникова хмарність", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 79d11512b..dcf63a875 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1288,7 +1288,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时", "North America · infrared imagery": "北美洲 · 红外影像", "Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用", - "Reduced motion · manual history available": "减弱动效 · 可手动浏览历史", "Rain radar · US": "降雨雷达 · 美国", "Lightning density · 15 min": "闪电密度 · 15 分钟", "Satellite clouds": "卫星云图", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 5135b13ce..5bea36d08 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1283,7 +1283,6 @@ "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時", "North America · infrared imagery": "北美洲 · 紅外影像", "Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用", - "Reduced motion · manual history available": "減弱動效 · 可手動瀏覽歷史", "Rain radar · US": "降雨雷達 · 美國", "Lightning density · 15 min": "閃電密度 · 15 分鐘", "Satellite clouds": "衛星雲圖", diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 9f5761ccc..9a014bbc6 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -9,7 +9,9 @@ import { GLOBAL_INFRARED_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS, atLeastFrameSpacing, + createWeatherClock, } from './clock.js'; +import { worldClock } from '../../time/worldClock.js'; import { formatUtcDayMonth, t, msg } from '../../i18n/index.js'; import { formatScaleLabel } from '../../ui/unitLabels.js'; @@ -86,12 +88,14 @@ function moveWorldTime(world, { play, latest, step }) { /** 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 - * may also name its source with `sourceName(kind)`, `kind` being 'radar', - * 'lightning' or 'satellite'; NOAA nowCOAST is named otherwise. */ + * opens its imagery at that time; one made live opens the latest. Its frames + * follow `clock`, a weather clock several layers share; without one the layer + * keeps its own on the page's world clock, for its lifetime. The feed may also + * name its source with `sourceName(kind)`, `kind` being 'radar', 'lightning' + * or 'satellite'; NOAA nowCOAST is named otherwise. */ export function createWeatherLayer({ feed, - clock, + clock: sharedClock, id = 'weather-radar', cesium = Cesium, createRendering = createWeatherRendering, @@ -101,6 +105,10 @@ export function createWeatherLayer({ } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Weather requires a snapshot source'); + const ownClock = sharedClock + ? null + : createWeatherClock({ world: worldClock }); + const clock = sharedClock ?? ownClock; // A feed may also supply decoded frames; see weatherFrameSource. const radar = id === 'weather-radar'; const lightning = id === 'weather-lightning'; @@ -128,11 +136,8 @@ export function createWeatherLayer({ request = null, listener = null; let enabled = false, - loading = false, - playing = false, - followLatest = true; + loading = false; let generation = 0, - timer = null, error = null, removeCamera = null; let motion = null; @@ -172,13 +177,12 @@ export function createWeatherLayer({ return spaced; }; const maxGap = () => spacedTimes().gap; - const isLatest = () => - clock ? clock.getState().mode === 'latest' : followLatest; + const isLatest = () => clock.getState().mode === 'latest'; const getHost = () => imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' }; const notify = () => listener?.(); const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time); - const unsubscribeClock = clock?.subscribe(() => { + const unsubscribeClock = clock.subscribe(() => { if (!clock.getState().advancing || suspended()) rendering?.cancelPrefetch?.(); notify(); @@ -186,18 +190,10 @@ export function createWeatherLayer({ const observationDelayed = () => manifest?.latest && Date.now() - Date.parse(manifest.latest) > spacedTimes().delay; - const stop = () => { - if (clock) return; - playing = false; - rendering?.cancelPrefetch?.(); - clearTimeout(timer); - timer = null; - }; const suspended = () => hostHidden || documentRef?.hidden || motion?.matches; const onVisibility = () => { if (suspended()) rendering?.cancelPrefetch?.(); - if (clock) void clock.refresh(); - else if (suspended()) stop(); + void clock.refresh(); notify(); }; @@ -212,46 +208,15 @@ export function createWeatherLayer({ if (!changed || !rendering) return; ++generation; rendering.rehome?.(); - clearTimeout(timer); - timer = null; loading = Boolean(request && !request.signal.aborted); - if (clock && enabled) { - if (resume) { - void clock.refresh(); - if (!hostHidden && manifest && isLatest()) void show(manifest.latest); - } - notify(); - return; - } - if (resume && enabled && !hostHidden && manifest) { - const time = - followLatest || !manifest.times.includes(shownTime()) - ? manifest.latest - : shownTime(); - if ( - time !== shownTime() || - (satellite && rendering.getDiagnostics().infrared !== infrared) - ) - void show(time); - else schedule(); + if (enabled && resume) { + void clock.refresh(); + if (!hostHidden && manifest && isLatest()) void show(manifest.latest); } notify(); } const onMapStackChanged = () => checkHost(); - function schedule() { - if (clock) return; - clearTimeout(timer); - timer = null; - if (!enabled || !playing || suspended()) return; - timer = setTimeout(() => { - timer = null; - const times = manifest?.times || []; - const next = (times.indexOf(shownTime()) + 1) % times.length; - if (times[next]) void show(times[next]); - else stop(); - }, 2000); - } async function applyClockTime(time, { signal }) { if (!enabled || signal.aborted) return false; if (time !== null) return show(time, signal); @@ -265,7 +230,7 @@ export function createWeatherLayer({ return true; } function registerClock() { - if (!clock || unregisterClock || !rendering || !enabled) return; + if (unregisterClock || !rendering || !enabled) return; unregisterClock = clock.register({ id, get maxGapMs() { @@ -278,18 +243,11 @@ export function createWeatherLayer({ }); } async function warmNext(time) { - if ( - !enabled || - suspended() || - !(clock ? clock.getState().advancing : playing) - ) - return; + if (!enabled || suspended() || !clock.getState().advancing) return; + // The world plays on past the newest frame, never round to the first. const times = manifest?.times ?? []; - // 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; + const next = times[times.indexOf(time) + 1]; + if (!next) return; try { await rendering.prefetch?.(manifest, next, { infrared }); } catch { @@ -324,13 +282,10 @@ export function createWeatherLayer({ error = ok ? null : msg('Frame unavailable; previous observation retained'); - if (!ok) stop(); return ok; } catch { - if (owner === generation && !controller.signal.aborted) { + if (owner === generation && !controller.signal.aborted) error = msg('Weather imagery unavailable'); - stop(); - } return false; } finally { signal?.removeEventListener('abort', abort); @@ -338,8 +293,7 @@ export function createWeatherLayer({ if (owner === generation) { loading = Boolean(request && !request.signal.aborted); notify(); - if (clock && isLatest()) void clock.refresh(); - schedule(); + if (isLatest()) void clock.refresh(); } } } @@ -407,26 +361,20 @@ export function createWeatherLayer({ noFrame = false; request?.abort(); request = null; - stop(); + rendering?.cancelPrefetch?.(); rendering?.clear(); manifest = null; loading = false; error = null; - followLatest = true; }, async update(_viewer, { signal } = {}) { if (!enabled) return false; checkHost(false); - clearTimeout(timer); - timer = null; request?.abort(); const controller = new AbortController(); if (signal?.aborted) controller.abort(signal.reason); request = controller; - const abort = () => { - controller.abort(signal.reason); - stop(); - }; + const abort = () => controller.abort(signal.reason); signal?.addEventListener('abort', abort, { once: true }); loading = true; notify(); @@ -442,36 +390,15 @@ export function createWeatherLayer({ error = msg( 'Weather source unavailable; previous observation retained', ); - stop(); return true; } manifest = snapshot; error = null; // The shown frame may stay; its credit follows the refreshed manifest. rendering?.updateSnapshot?.(snapshot); - if (clock) { - await clock.refresh(); - if ( - isLatest() && - request === controller && - !controller.signal.aborted - ) - await show(snapshot.latest, controller.signal); - return ( - enabled && - !signal?.aborted && - (!controller.signal.aborted || request !== controller) - ); - } - const time = - followLatest || !snapshot.times.includes(shownTime()) - ? snapshot.latest - : shownTime(); - if ( - shownTime() !== time || - (satellite && rendering.getDiagnostics().infrared !== infrared) - ) - await show(time, controller.signal); + await clock.refresh(); + if (isLatest() && request === controller && !controller.signal.aborted) + await show(snapshot.latest, controller.signal); return ( enabled && !signal?.aborted && @@ -481,7 +408,6 @@ export function createWeatherLayer({ if (controller.signal.aborted || request !== controller) return enabled && !signal?.aborted && request !== controller; error = cause?.message || msg('Weather unavailable'); - stop(); return true; } finally { signal?.removeEventListener('abort', abort); @@ -489,7 +415,6 @@ export function createWeatherLayer({ request = null; loading = Boolean(rendering?.getDiagnostics().loading); notify(); - schedule(); } } }, @@ -524,19 +449,16 @@ export function createWeatherLayer({ noFrame = false; request?.abort(); request = null; - stop(); + rendering?.cancelPrefetch?.(); manifest = null; error = null; loading = false; - followLatest = true; rendering?.clear(); - if (clock) void clock.refresh(); + void clock.refresh(); if (enabled) void layer.update(viewer); } if (infraredChanged && enabled && manifest) { - clearTimeout(timer); - timer = null; - if (clock && !isLatest()) void clock.refresh(); + if (!isLatest()) void clock.refresh(); else void show(shownTime() || manifest.latest); } if ( @@ -562,36 +484,9 @@ export function createWeatherLayer({ }), ); } - // 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 && - enabled && - manifest?.times?.length > 1 && - !suspended() - ) { - playing = !playing; - followLatest = false; - if (playing) schedule(); - else stop(); - } - if (!clock && params.latest === true && manifest) { - stop(); - followLatest = true; - void show(manifest.latest); - } - if (!clock && [-1, 1].includes(params.step) && manifest && !loading) { - stop(); - followLatest = false; - const at = manifest.times.indexOf(shownTime()); - const next = Math.max( - 0, - Math.min(manifest.times.length - 1, at + params.step), - ); - void show(manifest.times[next]); - } + // Time is the world's: these move the world clock as its timeline + // does, and every layer that follows it moves along. + if (enabled) moveWorldTime(clock.world, params); notify(); }, getParams() { @@ -602,13 +497,13 @@ export function createWeatherLayer({ : { product, opacity }; }, getRowControls() { - const shared = clock?.getState(); + const shared = clock.getState(); const followLatest = isLatest(); // Source-described text is data: shown as is, never translated. const display = weatherDisplay(manifest); const missing = noFrame && - shared?.mode === 'history' && + shared.mode === 'history' && shared.products.find((entry) => entry.id === id)?.selected === null ? maxGap() / 60_000 < 60 ? t('No frame within {gap} min of {time}', { @@ -734,9 +629,7 @@ export function createWeatherLayer({ const stops = display.legend ?? (radar ? STOPS : lightning ? LIGHTNING_STOPS : []); const infoMotion = motion?.matches - ? clock - ? t('Reduced motion · history playback unavailable') - : t('Reduced motion · manual history available') + ? t('Reduced motion · history playback unavailable') : ''; const controls = { readout: true, @@ -926,27 +819,23 @@ export function createWeatherLayer({ }; }, getDiagnostics() { - const shared = clock?.getState(); + const shared = clock.getState(); return { ...rendering?.getDiagnostics(), - playing: shared ? shared.advancing : playing && !hostHidden, + playing: shared.advancing, followLatest: isLatest(), - ...(shared - ? { - clock: { - mode: shared.mode, - target: shared.target, - playing: shared.advancing, - }, - } - : {}), + clock: { + mode: shared.mode, + target: shared.target, + playing: shared.advancing, + }, historyFrames: manifest?.times?.length || 0, - timerActive: timer !== null, }; }, destroy() { layer.disable(); - unsubscribeClock?.(); + unsubscribeClock(); + ownClock?.destroy(); removeCamera?.(); removeCamera = null; motion?.removeEventListener?.('change', onVisibility); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 78f2875bd..0748d69df 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1,8 +1,9 @@ -import test from 'node:test'; +import test, { afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { createWeatherRendering, READY_WAKE_MS } from './rendering.js'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; +import { worldClock } from '../../time/worldClock.js'; import * as Cesium from 'cesium'; import { Color, ImageryLayerCollection, GeographicTilingScheme } from 'cesium'; import { NO_IMAGERY_HOST } from './imageryHost.js'; @@ -482,6 +483,14 @@ test('failed and timed-out stages retain the previous observation; a healthy rep h.rendering.clear(); }); +// A layer built without a clock keeps its own on the page's world clock: +// each test leaves that clock live, with no layer of its own on it. +const harnessed = new Set(); +afterEach(() => { + for (const layer of harnessed) layer.destroy(); + harnessed.clear(); + worldClock.goLive(); +}); function layerHarness({ clock, reducedMotion = false, @@ -562,6 +571,7 @@ function layerHarness({ }); layer.init(viewer); layer.enable(); + harnessed.add(layer); return { layer, rendering, @@ -575,72 +585,91 @@ function layerHarness({ }; } -test('history has one owned timer, stops while hidden, and releases all work on disable/destroy', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); +test('without a clock the layer keeps one of its own on the world clock and lets it go on destroy', async () => { const h = layerHarness(); const update = h.layer.update(); await flush(); h.stages[0].finish(); await update; + assert.equal(worldClock.follows('weather-radar'), true); + for (const time of times) + assert.ok(worldClock.coverage().marks.includes(Date.parse(time))); h.layer.setParams({ play: true }); - assert.equal(h.layer.getDiagnostics().timerActive, true); - t.mock.timers.tick(2000); - assert.equal(h.stages.length, 2); - assert.equal( - h.layer.getDiagnostics().timerActive, - false, - 'next timer waits for stage settlement', - ); - h.stages[1].finish(); - await flush(); - assert.equal(h.layer.getDiagnostics().timerActive, true); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getState().stepping, false); + assert.equal(h.layer.getDiagnostics().playing, true); h.documentRef.hidden = true; h.documentRef.emit('visibilitychange'); - assert.equal(h.layer.getDiagnostics().playing, false); - assert.equal(h.layer.getDiagnostics().timerActive, false); - t.mock.timers.tick(20_000); - assert.equal(h.stages.length, 2); + await flush(); + assert.equal(worldClock.isPlaying(), true, 'hiding suspends the layer only'); h.documentRef.hidden = false; h.documentRef.emit('visibilitychange'); h.layer.setParams({ play: true }); + assert.equal(worldClock.isPlaying(), false); h.layer.disable(); - assert.equal(h.layer.getDiagnostics().timerActive, false); assert.equal(h.layer.getDiagnostics().historyFrames, 0); - t.mock.timers.tick(20_000); - assert.equal(h.stages.length, 2); h.layer.destroy(); + assert.equal(worldClock.follows('weather-radar'), false); assert.equal(h.documentRef.size, 0); assert.equal(h.motion.size, 0); assert.equal(h.moveEnd.size, 0); assert.ok(h.clears() >= 1); }); -test('reduced motion blocks autoplay but permits manual history and stops newly suspended playback', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); +test('reduced motion offers no play; steps still move the world clock from mark to mark', async () => { const h = layerHarness({ reducedMotion: true }); const update = h.layer.update(); await flush(); h.stages[0].finish(); await update; - h.layer.setParams({ play: true }); - assert.equal(h.layer.getDiagnostics().timerActive, false); assert.equal( h.layer.getRowControls().chips.some((chip) => chip.id === 'play'), false, ); + // 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. h.layer.setParams({ step: -1 }); - assert.equal(h.stages[1].time, times[1]); - h.stages[1].finish(); await flush(); - h.motion.matches = false; - h.motion.emit('change'); + assert.equal(worldClock.now(), Date.parse(times[2])); + h.layer.setParams({ step: -1 }); + await flush(); + assert.equal(worldClock.now(), Date.parse(times[1])); + assert.equal(h.stages.at(-1).time, times[1]); +}); + +test('a row steps forward and plays from a past time through the world clock', async () => { + const h = layerHarness(); + const update = h.layer.update(); + await flush(); + h.stages[0].finish(); + await update; + worldClock.seek(Date.parse(times[0])); + await flush(); + h.layer.setParams({ step: 1 }); + assert.equal(worldClock.now(), Date.parse(times[1])); + assert.equal(worldClock.isPlaying(), false); + await flush(); + assert.equal(h.stages.at(-1).time, times[1]); + h.layer.setParams({ step: 1 }); + assert.equal(worldClock.now(), Date.parse(times[2])); + h.layer.setParams({ step: 1 }); + assert.equal( + worldClock.now(), + Date.parse(times[2]), + 'no mark after the newest frame', + ); + worldClock.seek(Date.parse(times[1])); + await flush(); h.layer.setParams({ play: true }); - assert.equal(h.layer.getDiagnostics().playing, true); - h.motion.matches = true; - h.motion.emit('change'); - assert.equal(h.layer.getDiagnostics().playing, false); - assert.equal(h.layer.getDiagnostics().timerActive, false); - h.layer.destroy(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getState().stepping, false); + assert.ok( + worldClock.now() - Date.parse(times[1]) < 1_000, + 'away from live, play goes on from the time shown', + ); + h.layer.setParams({ step: 1 }); + assert.equal(worldClock.isPlaying(), false, 'a step holds play'); + assert.equal(worldClock.now(), Date.parse(times[2])); }); test('disable invalidates an abort-insensitive source result and an already staged frame', async (t) => { @@ -669,7 +698,6 @@ test('disable invalidates an abort-insensitive source result and an already stag await next; await flush(); assert.equal(h.layer.getStats().count, 0); - assert.equal(h.layer.getDiagnostics().timerActive, false); assert.equal(h.layer.getStats().loading, false); h.layer.destroy(); }); @@ -729,7 +757,6 @@ test('update cancellation reaches imagery after source acquisition has completed assert.equal(h.stages[0].signal.aborted, true); assert.equal(h.layer.getStats().count, 0); assert.equal(h.layer.getStats().loading, false); - assert.equal(h.layer.getDiagnostics().timerActive, false); h.layer.destroy(); }); @@ -837,21 +864,22 @@ test('coverage navigation requires the shared shell handoff and detaches cleanly h.layer.destroy(); }); -test('manifest refresh during a manual history stage does not strand loading controls', async () => { +test('manifest refresh during a history stage restages the same time and does not strand loading controls', async () => { const h = layerHarness(); const first = h.layer.update(); await flush(); h.stages[0].finish(); await first; - h.layer.setParams({ step: -1 }); + worldClock.seek(Date.parse(times[1])); + await flush(); assert.equal(h.layer.getStats().loading, true); - await h.layer.update(); - assert.equal( - h.stages.length, - 2, - 'metadata refresh preserves the staged manual selection', - ); - h.stages[1].finish(); + const refreshed = h.layer.update(); + await flush(); + assert.equal(h.stages.length, 3); + assert.equal(h.stages[1].signal.aborted, true); + assert.equal(h.stages[2].time, times[1], 'the refresh keeps the time'); + h.stages[2].finish(); + await refreshed; await flush(); assert.equal(h.layer.getStats().loading, false); assert.equal(h.layer.getStats().observedAt, times[1]); @@ -881,7 +909,8 @@ test('historical observations do not relabel a fresh lightning feed as stale', a await flush(); h.stages[0].finish(); await first; - h.layer.setParams({ step: -1 }); + worldClock.seek(Date.parse(old)); + await flush(); h.stages[1].finish(); await flush(); assert.equal(h.layer.getStats().stale, false); @@ -1256,8 +1285,7 @@ test('404 fails a frame without retry', async () => { h.rendering.clear(); }); -test('no host pauses history, refreshes metadata, and restores retained time and playback', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); +test('no host holds the shown history frame, refreshes metadata, and restores the retained time', async () => { let refreshes = 0; const h = layerHarness({ feed: { @@ -1273,28 +1301,23 @@ test('no host pauses history, refreshes metadata, and restores retained time and await flush(); h.stages[0].finish(); await update; - h.layer.setParams({ play: true }); - t.mock.timers.tick(2000); + worldClock.seek(Date.parse(times[0])); + await flush(); assert.equal(h.stages.length, 2); + h.stages[1].finish(); + await flush(); host = { collection: null, kind: 'none' }; h.eventTarget.emit('gev:map-stack-changed'); await flush(); - assert.equal(h.layer.getDiagnostics().playing, false); - assert.equal(h.layer.getDiagnostics().timerActive, false); assert.equal(h.layer.getRowControls().summary.status, NO_IMAGERY_HOST); assert.equal(h.layer.getRowControls().info, NO_IMAGERY_HOST); await h.layer.update(); assert.equal(refreshes, 2); - t.mock.timers.tick(10_000); - assert.equal(h.stages.length, 2); - assert.equal(h.layer.getDiagnostics().time, times[2]); + assert.equal(h.stages.length, 2, 'nothing stages without a host'); host = { collection: {}, kind: 'globe' }; h.eventTarget.emit('gev:map-stack-changed'); - assert.equal(h.layer.getDiagnostics().playing, true); - assert.equal(h.layer.getDiagnostics().timerActive, true); - t.mock.timers.tick(2000); - assert.equal(h.stages.length, 3); - assert.equal(h.stages[2].time, times[0]); + await flush(); + assert.equal(h.stages.at(-1).time, times[0], 'the retained time restages'); h.layer.destroy(); assert.equal(h.eventTarget.size, 0); }); @@ -1431,25 +1454,21 @@ for (const [id, product] of [ const [shell] = h.shells(); assert.equal(shell.show, true); - layer.setParams({ play: true }); - t.mock.timers.tick(2000); + worldClock.seek(Date.parse(times[0])); await flush(); assert.equal( layer.getDiagnostics().time, times[0], - 'history advances at street level', + 'history moves at street level', ); camera.positionCartographic.height = 59_999; camera.moveEnd.emit(); assert.equal(shell.show, true, 'no height gate'); - assert.equal(layer.getDiagnostics().playing, true); - assert.equal(layer.getDiagnostics().timerActive, true); host = { collection: null, kind: 'none' }; events.emit('gev:map-stack-changed'); assert.equal(shell.show, false); assert.equal(layer.getRowControls().summary.status, NO_IMAGERY_HOST); - assert.equal(layer.getDiagnostics().timerActive, false); host = { collection: h.viewer.imageryLayers, kind: 'globe' }; events.emit('gev:map-stack-changed'); assert.equal(shell.destroyed, true); @@ -1648,10 +1667,12 @@ test('satellite mode chips restage history without changing time or latest-follo await flush(); h.stages[0].finish(); await update; - h.layer.setParams({ step: -1 }); + worldClock.seek(Date.parse(times[1])); + await flush(); h.stages[1].finish(); await flush(); h.layer.setParams({ infrared: 'full' }); + await flush(); assert.equal(h.stages[2].time, times[1]); assert.equal(h.stages[2].infrared, 0); assert.equal(h.layer.getParams().infrared, 0); From 7cb854c923f48dbeb82a3b05d31f3bd7652ed731 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 20:28:56 -0800 Subject: [PATCH 3/7] The timeline marks launches and strong earthquakes, not every weather frame The timeline drew a tick for every registered mark, and the weather clock registered every radar, satellite and lightning frame, which come every few minutes, so the track was a wall of dashes and the arrow keys crept from frame to frame. Weather products now register only the span of their frames (start and end, as before) with no marks. Holds, the gate and the next-frame wait while the world plays are unchanged. A weather row's step param goes to the previous or next weather frame (every shown product's frames, and the row's own while it is suspended) rather than to the timeline's marks. The Earthquakes layer registers with the world clock once, at init, and unregisters on destroy. While on, its quakes of magnitude 6 and up (EARTHQUAKE_MARK_MAGNITUDE) are marks, and its coverage is their own span, or none without any, so it never stretches the timeline beyond the feed. Off, it has none. It tells the clock when the set of marks changes. The map still draws every M2.5+ quake. Launches stay marks. With no mark ahead or behind, the arrow keys step along the track as shown. --- .../0000-timeline-one-control-and-marks.md | 10 +++++ docs/CURRENT-STATE.md | 16 ++++++-- scripts/package-boundaries.json | 3 +- src/layers/earthquakes/index.js | 29 ++++++++++++++ src/layers/earthquakes/model.js | 28 +++++++++++++ src/layers/earthquakes/ownership.test.mjs | 39 +++++++++++++++++++ src/layers/weather/clock.js | 21 ++++++---- src/layers/weather/clock.test.mjs | 19 +++++---- src/layers/weather/index.js | 29 +++++++++----- src/layers/weather/weather.test.mjs | 20 ++++++---- src/ui/views/panels/WorldTimeline.vue | 8 ++-- 11 files changed, 181 insertions(+), 41 deletions(-) diff --git a/changelog.d/0000-timeline-one-control-and-marks.md b/changelog.d/0000-timeline-one-control-and-marks.md index fdf384488..6fa6ca5be 100644 --- a/changelog.d/0000-timeline-one-control-and-marks.md +++ b/changelog.d/0000-timeline-one-control-and-marks.md @@ -1,3 +1,13 @@ +- The timeline marks only events: launches, and earthquakes of magnitude 6 + and up while the Earthquakes layer is on (about one every two days + worldwide). It no longer draws a tick for every weather frame, which came + every few minutes and made the track a wall of dashes; the weather frames + still set the time the timeline covers, and Play still waits at each next + frame until it loads. With no mark ahead, the arrow keys step along the + track. The map still draws every earthquake, and an earthquake layer never + widens the timeline beyond its strong quakes' own times. A weather row's + `step` param goes to the previous or next weather frame (samehkhamis, + #0000). - 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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26e6a5e3b..37b8b6615 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1781,7 +1781,11 @@ Earthquake rendering is exposed through `./layers/earthquakes`. The layer owns its entities and request lifecycle; the application supplies the overlay host and snapshot source. The standalone adapter keeps the existing USGS daily feed, M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or -destroying the layer cancels pending work and ignores late results. +destroying the layer cancels pending work and ignores late results. While on, +the layer registers its quakes of magnitude 6 and up +(`EARTHQUAKE_MARK_MAGNITUDE`) with the world clock as timeline marks, spanning +only their own times, and none while off; the map still draws every M2.5+ +quake. ## Rendered-mesh floor readings — November 9, 2026 @@ -8533,9 +8537,13 @@ 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 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. +timeline is the only time control. Each product's advertised times span time on +that timeline but are not marks on it: the timeline marks only events (launches, +and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys +step to those, or along the track where there are none. A weather row's `step` +param goes to the previous or next weather frame. 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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b819fa2c2..95e87d082 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1762,7 +1762,8 @@ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", - "src/layers/earthquakes/records.js" + "src/layers/earthquakes/records.js", + "src/time/worldClock.js" ], "external": ["cesium"] }, diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index 0d522fcb9..66d8f3d70 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { worldClock } from '../../time/worldClock.js'; import { EARTHQUAKE_OVERLAY_SOURCE_ID, EARTHQUAKE_OVERLAY_COHORT_LIMIT, @@ -7,6 +8,7 @@ import { createEarthquakeOverlayEntry, selectEarthquakeOverlayCohort, mapAnalystRecord, + earthquakeCoverage, } from './model.js'; export * from './model.js'; export { createUsgsEarthquakeSource } from './source.js'; @@ -27,6 +29,19 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { let _lastUpdate = null; let _lastError = null; let _enabled = false; + // The quakes last drawn, as the timeline reads them ({ mag, time }). + let _events = []; + let _uncover = null; + // The marks last told to the clock, so a refresh with the same strong + // quakes does not tell it again. + let _marksKey = null; + const coverage = () => (_enabled ? earthquakeCoverage(_events) : null); + const coverageChanged = () => { + const key = coverage()?.marks.join() ?? null; + if (key === _marksKey) return; + _marksKey = key; + worldClock.coverageChanged(); + }; const layer = { id: 'earthquakes', @@ -41,6 +56,10 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { _dataSource = new Cesium.CustomDataSource('earthquakes'); _dataSource.show = false; viewer.dataSources.add(_dataSource); + // While on, the strong quakes are marks on the world clock's timeline. + // Registered once; off, the layer has no time to show. + _marksKey = null; + _uncover = worldClock.register({ id: 'earthquakes', coverage }); _count = 0; _lastUpdate = null; _lastError = null; @@ -55,6 +74,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { // layer has no per-frame animator to keep the render loop alive for. if (_dataSource) _dataSource.show = true; overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true); + coverageChanged(); }, disable(viewer) { @@ -64,6 +84,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { if (_dataSource) _dataSource.show = false; overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); + coverageChanged(); }, async update(viewer) { @@ -77,6 +98,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { return false; const nextEntities = []; + const nextEvents = []; let count = 0; const overlayEntries = []; @@ -91,6 +113,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { time, } of rows) { count++; + nextEvents.push({ mag, time }); const baseRadius = Math.pow(2, mag) * 1000; const color = depthColor(depthKm || 0); const isSignificant = mag >= 5.0; @@ -137,6 +160,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { _dataSource.entities.removeAll(); for (const entity of nextEntities) _dataSource.entities.add(entity); + _events = nextEvents; + coverageChanged(); if (_enabled) { overlayHost.setEntries( EARTHQUAKE_OVERLAY_SOURCE_ID, @@ -176,6 +201,10 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { viewer.dataSources.remove(_dataSource, true); _dataSource = null; } + _events = []; + _uncover?.(); + _uncover = null; + _marksKey = null; _count = 0; _lastUpdate = null; _lastError = null; diff --git a/src/layers/earthquakes/model.js b/src/layers/earthquakes/model.js index c88063b7b..1c2a68836 100644 --- a/src/layers/earthquakes/model.js +++ b/src/layers/earthquakes/model.js @@ -3,6 +3,34 @@ export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes'; export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96; export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48; +/** + * The smallest magnitude the timeline marks. Most quakes are small shakes; + * M6 and up come about once every two days worldwide, so each is an event + * worth stepping to. The map still draws every quake. + */ +export const EARTHQUAKE_MARK_MAGNITUDE = 6; + +/** + * The time a world clock's timeline can show for `events` (`{mag, time}`, + * time in ms): the span of the quakes of EARTHQUAKE_MARK_MAGNITUDE and up, + * each one a mark, or null with none, so the layer never stretches the + * timeline past what its feed covers. + * @returns {{start: number, end: number, marks: number[]} | null} + */ +export function earthquakeCoverage(events) { + const marks = [ + ...new Set( + events + .filter( + ({ mag, time }) => + mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time), + ) + .map(({ time }) => time), + ), + ].sort((a, b) => a - b); + return marks.length ? { start: marks[0], end: marks.at(-1), marks } : null; +} + /** * Color by depth: * - Shallow (<70km): Red diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs index 9951935de..a86a797a1 100644 --- a/src/layers/earthquakes/ownership.test.mjs +++ b/src/layers/earthquakes/ownership.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createEarthquakesLayer } from './index.js'; import { createUsgsEarthquakeSource } from './source.js'; +import { EARTHQUAKE_MARK_MAGNITUDE } from './model.js'; +import { worldClock } from '../../time/worldClock.js'; function harness(source) { const sources = []; const events = []; @@ -88,3 +90,40 @@ test('USGS body completion honors cancellation even with an uncooperative transp name: 'AbortError', }); }); +test('the timeline marks only strong quakes, while the layer is on', async () => { + assert.equal(EARTHQUAKE_MARK_MAGNITUDE, 6); + const rows = [ + { ...row, stableId: 'small', mag: 4.5, time: 1_000 }, + { ...row, stableId: 'strong', mag: 6, time: 5_000 }, + { ...row, stableId: 'great', mag: 7.2, time: 3_000 }, + { ...row, stableId: 'untimed', mag: 6.5, time: null }, + ]; + let snapshot = rows; + const h = harness({ getSnapshot: async () => snapshot }); + assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show'); + let changes = 0; + const unsubscribe = worldClock.subscribe(() => changes++); + await h.layer.update(h.viewer); + // Every quake is drawn; only M6 and up are marks, spanning their own time. + assert.equal(h.sources[0].entities.values.length, 4); + assert.deepEqual(worldClock.coverage(), { + start: 3_000, + end: 5_000, + marks: [3_000, 5_000], + }); + const told = changes; + await h.layer.update(h.viewer); + assert.equal(changes, told, 'the same strong quakes are not told again'); + snapshot = rows.slice(0, 1); + await h.layer.update(h.viewer); + assert.equal(worldClock.coverage(), null, 'small shakes alone cover no time'); + snapshot = rows; + await h.layer.update(h.viewer); + h.layer.disable(h.viewer); + assert.equal(worldClock.coverage(), null, 'off, no time to show'); + h.layer.enable(h.viewer); + assert.deepEqual(worldClock.coverage().marks, [3_000, 5_000]); + h.layer.destroy(); + assert.equal(worldClock.coverage(), null); + unsubscribe(); +}); diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index 672415973..f46d5fc3d 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -198,11 +198,13 @@ export function createWeatherClock({ throw new Error(`Duplicate weather product: ${product.id}`); const entry = { product, controller: null }; registry.set(product.id, entry); - // The product moves with the world clock, and its frames are time the - // world clock's timeline can show while it is on, suspended or not, so - // the way back to live stays on screen. Both calls notify the world's - // listeners, which may destroy this clock: the release is in place - // first, and anything acquired after a destroy is let go at once. + // The product moves with the world clock, and the span of its frames is + // time the world clock's timeline can show while it is on, suspended or + // not, so the way back to live stays on screen. The frames are not + // marks: they come every few minutes, and the timeline marks only + // events. Both calls notify the world's listeners, which may destroy + // this clock: the release is in place first, and anything acquired + // after a destroy is let go at once. let unhold = null; let uncover = null; const release = () => { @@ -216,9 +218,12 @@ export function createWeatherClock({ uncover = world.register({ id: product.id, coverage: () => { - const marks = timesFor(product).map((time) => Date.parse(time)); - return marks.length - ? { start: marks[0], end: marks.at(-1), marks } + const times = timesFor(product); + return times.length + ? { + start: Date.parse(times[0]), + end: Date.parse(times.at(-1)), + } : null; }, }); diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 5a5c36957..1041f69d4 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -367,7 +367,7 @@ test('weather clocks sharing a world: the last one destroyed leaves it live', () assert.equal(world.isPlaying(), false); }); -test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => { +test('each product’s frames span world clock coverage, with no marks, and support the timeline while shown', (t) => { const { clock, world } = harness(t); assert.equal(world.coverage(), null); assert.equal(world.getState().supported, false); @@ -377,11 +377,15 @@ test('each product’s frames are world clock coverage, and support the timeline assert.deepEqual(world.coverage(), { start: Date.parse(time(0)), end: Date.parse(time(10)), - marks: [0, 5, 10].map((m) => Date.parse(time(m))), + marks: [], }); assert.equal(world.getState().supported, true); unregister(); - assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]); + assert.deepEqual(world.coverage(), { + start: Date.parse(time(5)), + end: Date.parse(time(5)), + marks: [], + }); clock.destroy(); assert.equal(world.coverage(), null); assert.equal(world.getState().supported, false); @@ -394,10 +398,11 @@ test('a suspended product keeps its coverage, so the way back to live stays', (t radar.suspended = true; void clock.refresh(); assert.equal(world.getState().supported, true); - assert.deepEqual( - world.coverage().marks, - [0, 10].map((m) => Date.parse(time(m))), - ); + assert.deepEqual(world.coverage(), { + start: Date.parse(time(0)), + end: Date.parse(time(10)), + marks: [], + }); }); test('new frames reach the world clock through refresh', (t) => { diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 9a014bbc6..ce2f70c5c 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -57,11 +57,12 @@ const utc = (value) => /** * 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. + * (from the start of the covered time when live) and `latest` goes live. + * `step` (-1 or 1) goes to the previous or next of `frames`, the weather + * frames; the timeline does not mark them, so this is the row's own way to + * step through them. */ -function moveWorldTime(world, { play, latest, step }) { +function moveWorldTime(world, frames, { play, latest, step }) { if (!world) return; if (play === true) { const state = world.getState(); @@ -75,11 +76,14 @@ function moveWorldTime(world, { play, latest, step }) { if (latest === true) world.goLive(); if (step === -1 || step === 1) { const at = world.getState().timeMs; - const marks = world.coverage()?.marks ?? []; + const times = frames + .map((time) => Date.parse(time)) + .filter(Number.isFinite) + .sort((a, b) => a - b); const to = step < 0 - ? marks.findLast((mark) => mark < at) - : marks.find((mark) => mark > at); + ? times.findLast((frame) => frame < at) + : times.find((frame) => frame > at); if (to === undefined) return; world.pause(); world.seek(to); @@ -485,8 +489,15 @@ export function createWeatherLayer({ ); } // Time is the world's: these move the world clock as its timeline - // does, and every layer that follows it moves along. - if (enabled) moveWorldTime(clock.world, params); + // does, and every layer that follows it moves along. A step goes + // through every shown observation's frames, and this row's own even + // while it is suspended. + if (enabled) + moveWorldTime( + clock.world, + [...clock.getTimeline(), ...(manifest?.times ?? [])], + params, + ); notify(); }, getParams() { diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 0748d69df..296acbb30 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -592,8 +592,12 @@ test('without a clock the layer keeps one of its own on the world clock and lets h.stages[0].finish(); await update; assert.equal(worldClock.follows('weather-radar'), true); - for (const time of times) - assert.ok(worldClock.coverage().marks.includes(Date.parse(time))); + // The frames span the timeline's time; they are not marks on it. + assert.deepEqual(worldClock.coverage(), { + start: Date.parse(times[0]), + end: Date.parse(times.at(-1)), + marks: [], + }); h.layer.setParams({ play: true }); assert.equal(worldClock.isPlaying(), true); assert.equal(worldClock.getState().stepping, false); @@ -616,7 +620,7 @@ test('without a clock the layer keeps one of its own on the world clock and lets assert.ok(h.clears() >= 1); }); -test('reduced motion offers no play; steps still move the world clock from mark to mark', async () => { +test('reduced motion offers no play; steps still move the world clock from frame to frame', async () => { const h = layerHarness({ reducedMotion: true }); const update = h.layer.update(); await flush(); @@ -626,8 +630,8 @@ test('reduced motion offers no play; steps still move the world clock from mark h.layer.getRowControls().chips.some((chip) => chip.id === 'play'), false, ); - // 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. + // A step back from live goes to the newest frame, and the next one back + // to the frame before it. h.layer.setParams({ step: -1 }); await flush(); assert.equal(worldClock.now(), Date.parse(times[2])); @@ -656,7 +660,7 @@ test('a row steps forward and plays from a past time through the world clock', a assert.equal( worldClock.now(), Date.parse(times[2]), - 'no mark after the newest frame', + 'no frame after the newest', ); worldClock.seek(Date.parse(times[1])); await flush(); @@ -1829,8 +1833,8 @@ test('a row steps the world clock through every registered observation; missing .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. + // 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]); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 866380638..2c8e2981b 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -294,10 +294,10 @@ const scrub = (event) => { } store.scrub(Number(event.target.value), viewLength()); }; -// Arrows go to the previous or next mark (a launch, a frame), or a step along -// the track as shown; Page keys take a tenth of it; Home and End go to the -// ends of all of it. Plus and minus zoom, and zero shows it all; with a -// modifier they are the browser's own zoom. +// Arrows go to the previous or next mark (a launch, a strong earthquake), or +// a step along the track as shown where there is none; Page keys take a tenth +// of it; Home and End go to the ends of all of it. Plus and minus zoom, and +// zero shows it all; with a modifier they are the browser's own zoom. const ZOOM_KEYS = ['+', '=', '-', '0']; const onKey = (event) => { if (ZOOM_KEYS.includes(event.key)) { From 558e4a919ac04af4c7fe7ed72960467251e18edd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 20:30:01 -0800 Subject: [PATCH 4/7] Timeline marks carry labels; a tap by a tick goes to its mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A layer's world clock coverage may now give each mark as an object, { time, label?, id?, kind? }, as well as a bare epoch ms number, and the two may be mixed. worldClock.coverage() keeps marks as the sorted list of distinct times, so existing readers are unchanged, and adds markDetails: the merged marks in time order (then kind, label and id), each frozen and holding only the fields given. Marks given alike by several sources appear once, a bare time is dropped where a labelled mark shares it, and a mark with no finite time is ignored. The contract is documented in the worldClock JSDoc and docs/APPLICATION.md. Launches label their marks with the mission's name (the launch's where it has none), cut to 27 characters in capitals as the mission index shows names. Earthquakes of magnitude 6 and up label theirs with the magnitude as the map shows it and the feed's place cut to 40 characters, such as "M6.4 · 113 km SSE of Lata, Solomon Islands"; the layer now tells the clock when the labels change as well as the times. On the timeline, a pointer within 6 px of a tick shows its label over the track (nothing for an unlabelled mark), and a tap there goes exactly to the mark's time instead of the point tapped. A tap elsewhere and a drag work as before, and ticks stay ticks. The arrow keys still step from mark to mark; with the handle on a mark, its labels follow the time in the handle's aria-valuetext, and show over the track while it has focus. --- .../0000-timeline-one-control-and-marks.md | 10 ++ docs/APPLICATION.md | 18 ++++ docs/CURRENT-STATE.md | 14 ++- src/data/rocketLaunches.test.mjs | 10 +- src/layers/earthquakes/index.js | 8 +- src/layers/earthquakes/model.js | 62 +++++++++--- src/layers/earthquakes/ownership.test.mjs | 37 ++++++- src/layers/launches/lifecycle.js | 27 ++++-- src/layers/weather/clock.test.mjs | 3 + src/layers/weather/weather.test.mjs | 1 + src/time/snapshotTimeline.test.mjs | 1 + src/time/worldClock.js | 86 +++++++++++++++- src/time/worldClock.test.mjs | 53 +++++++++- src/ui/stores/worldTime.js | 5 +- src/ui/styles/world-timeline.css | 20 ++++ src/ui/views/panels/WorldTimeline.test.mjs | 88 +++++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 97 ++++++++++++++++--- 17 files changed, 490 insertions(+), 50 deletions(-) diff --git a/changelog.d/0000-timeline-one-control-and-marks.md b/changelog.d/0000-timeline-one-control-and-marks.md index 6fa6ca5be..6b619c3f1 100644 --- a/changelog.d/0000-timeline-one-control-and-marks.md +++ b/changelog.d/0000-timeline-one-control-and-marks.md @@ -1,3 +1,13 @@ +- Timeline marks have labels. Hovering a tick shows its label (a launch's + mission name, or an earthquake's magnitude and place, such as + `M6.4 · 113 km SSE of Lata, Solomon Islands`), and a tap on or within 6 px + of a tick goes exactly to its mark rather than to the point tapped; a tap + elsewhere and a drag work as before. The arrow keys still step from mark to + mark, and the handle then tells the mark's label with the time to screen + readers and shows it over the track while focused. A layer's world clock + coverage may give each mark as `{ time, label, id, kind }` as well as a + bare time; `worldClock.coverage()` keeps `marks` as the sorted times and + adds `markDetails`, the merged marks themselves (samehkhamis, #0000). - The timeline marks only events: launches, and earthquakes of magnitude 6 and up while the Earthquakes layer is on (about one every two days worldwide). It no longer draws a tick for every weather frame, which came diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index c268a5206..42660a10e 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -191,6 +191,24 @@ 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 layer gives the world clock's timeline the time it can show with +`worldClock.register({ id, coverage })` (`gods-eye-view/time`), which returns +the unregister; call `worldClock.coverageChanged()` when that time changes. +`coverage()` returns null while the layer has none, or +`{ start, end, marks? }` in epoch ms. `marks` are the moments worth going to, +drawn as ticks the timeline's arrow keys step to and a tap on or within 6 px of +snaps to. Each mark is a number, or `{ time, label?, id?, kind? }`: `label` is +short text shown when the pointer is on the tick and told with the time while +the handle is on the mark, `id` the layer's own key for the event and `kind` +what sort of event it is (`'launch'`, `'earthquake'`). The two forms may be +mixed; a mark without a finite time, and a `label`, `id` or `kind` that is not +a non-empty string, are ignored. `worldClock.coverage()` returns the union of +the registered layers, `{ start, end, marks, markDetails }`, or null: `marks` +is every mark's time once, in order, as numbers, and `markDetails` the marks in +time order, each a frozen `{ time, label?, id?, kind? }` holding only the +fields given, with marks given alike by several layers once and a bare time +left out where a mark with more to say shares it. 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/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 37b8b6615..8885629aa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1785,7 +1785,10 @@ destroying the layer cancels pending work and ignores late results. While on, the layer registers its quakes of magnitude 6 and up (`EARTHQUAKE_MARK_MAGNITUDE`) with the world clock as timeline marks, spanning only their own times, and none while off; the map still draws every M2.5+ -quake. +quake. Each mark is labelled with the magnitude as the map shows it and the +feed's place, cut to 40 characters (`earthquakeMarkLabel` in +`src/layers/earthquakes/model.js`: `M6.4 · 113 km SSE of Lata, Solomon +Islands`, or `M6.4` with no place). ## Rendered-mesh floor readings — November 9, 2026 @@ -8540,7 +8543,14 @@ clock moves all enabled observation rows with the world clock; the world timeline is the only time control. Each product's advertised times span time on that timeline but are not marks on it: the timeline marks only events (launches, and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys -step to those, or along the track where there are none. A weather row's `step` +step to those, or along the track where there are none. A mark may carry a +label (a launch's mission name, cut to 27 characters in capitals as the mission +index shows names; an earthquake's magnitude and place). The pointer within +6 px of a tick shows its label over the track, and a tap there goes to the +mark's time exactly; a tap elsewhere goes to the time under it and a drag +scrubs. With the handle on a mark, its label follows the time in the handle's +`aria-valuetext` and shows over the track while the handle has focus. The +coverage contract is in [APPLICATION.md](./APPLICATION.md). A weather row's `step` param goes to the previous or next weather frame. Each product selects its nearest frame at or before the world time, never a future frame or an interpolation. diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index f1c66f032..9a40c0e8d 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -1244,7 +1244,15 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show'); await rocketLaunchesLayer.update(); const at = Date.parse(launchTime); - assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] }); + // The launch is a mark labelled with its mission's name. + assert.deepEqual(worldClock.coverage(), { + start: at, + end: at, + marks: [at], + markDetails: [ + { time: at, label: 'GAUNTLET PAYLOAD', id: 'mission-runtime', kind: 'launch' }, + ], + }); assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline'); const entities = dataSources[0].entities.values; diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index 66d8f3d70..0999681b7 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -29,7 +29,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { let _lastUpdate = null; let _lastError = null; let _enabled = false; - // The quakes last drawn, as the timeline reads them ({ mag, time }). + // The quakes last drawn, as the timeline reads them + // ({ mag, time, place, usgsId }). let _events = []; let _uncover = null; // The marks last told to the clock, so a refresh with the same strong @@ -37,7 +38,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { let _marksKey = null; const coverage = () => (_enabled ? earthquakeCoverage(_events) : null); const coverageChanged = () => { - const key = coverage()?.marks.join() ?? null; + const covered = coverage(); + const key = covered ? JSON.stringify(covered.marks) : null; if (key === _marksKey) return; _marksKey = key; worldClock.coverageChanged(); @@ -113,7 +115,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { time, } of rows) { count++; - nextEvents.push({ mag, time }); + nextEvents.push({ mag, time, place, usgsId }); const baseRadius = Math.pow(2, mag) * 1000; const color = depthColor(depthKm || 0); const isSignificant = mag >= 5.0; diff --git a/src/layers/earthquakes/model.js b/src/layers/earthquakes/model.js index 1c2a68836..66471b59e 100644 --- a/src/layers/earthquakes/model.js +++ b/src/layers/earthquakes/model.js @@ -10,25 +10,55 @@ export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48; */ export const EARTHQUAKE_MARK_MAGNITUDE = 6; +/** The longest place a timeline mark's label carries, ellipsis and all. */ +const MARK_PLACE_LENGTH = 40; + +/** A magnitude as the map's labels show it: `M6.4`. */ +export const earthquakeMagnitudeTitle = (magnitude) => + `M${Number(magnitude).toFixed(1)}`; + /** - * The time a world clock's timeline can show for `events` (`{mag, time}`, - * time in ms): the span of the quakes of EARTHQUAKE_MARK_MAGNITUDE and up, - * each one a mark, or null with none, so the layer never stretches the + * A strong quake's timeline label: its magnitude and, where the feed names + * one, its place, cut short (`M6.4 · 113 km SSE of Lata, Solomon Islands`). + * @param {{mag: number, place?: string | null}} event + * @returns {string} + */ +export function earthquakeMarkLabel({ mag, place }) { + const title = earthquakeMagnitudeTitle(mag); + const where = + typeof place === 'string' ? place.replace(/\s+/g, ' ').trim() : ''; + if (!where) return title; + const short = + where.length > MARK_PLACE_LENGTH + ? `${where.slice(0, MARK_PLACE_LENGTH - 1).trimEnd()}…` + : where; + return `${title} · ${short}`; +} + +/** + * The time a world clock's timeline can show for `events` (`{mag, time, + * place?, usgsId?}`, time in ms): the span of the quakes of + * EARTHQUAKE_MARK_MAGNITUDE and up, each one a mark labelled with its + * magnitude and place, or null with none, so the layer never stretches the * timeline past what its feed covers. - * @returns {{start: number, end: number, marks: number[]} | null} + * @returns {{start: number, end: number, marks: Array<{time: number, label: string, id?: string, kind: string}>} | null} */ export function earthquakeCoverage(events) { - const marks = [ - ...new Set( - events - .filter( - ({ mag, time }) => - mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time), - ) - .map(({ time }) => time), - ), - ].sort((a, b) => a - b); - return marks.length ? { start: marks[0], end: marks.at(-1), marks } : null; + const marks = events + .filter( + ({ mag, time }) => + mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time), + ) + .map((event) => ({ + time: event.time, + label: earthquakeMarkLabel(event), + ...(event.usgsId ? { id: String(event.usgsId) } : {}), + kind: 'earthquake', + })) + .sort((a, b) => a.time - b.time); + return marks.length + ? { start: marks[0].time, end: marks.at(-1).time, marks } + : null; } /** @@ -65,7 +95,7 @@ export function createEarthquakeOverlayEntry({ id: String(id), position, variant: 'label', - title: `M${mag.toFixed(1)}`, + title: earthquakeMagnitudeTitle(mag), accent, priority: Math.round(mag * 1000), collisionGroup: 'ambient-label', diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs index a86a797a1..c17514184 100644 --- a/src/layers/earthquakes/ownership.test.mjs +++ b/src/layers/earthquakes/ownership.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createEarthquakesLayer } from './index.js'; import { createUsgsEarthquakeSource } from './source.js'; -import { EARTHQUAKE_MARK_MAGNITUDE } from './model.js'; +import { EARTHQUAKE_MARK_MAGNITUDE, earthquakeMarkLabel } from './model.js'; import { worldClock } from '../../time/worldClock.js'; function harness(source) { const sources = []; @@ -94,8 +94,15 @@ test('the timeline marks only strong quakes, while the layer is on', async () => assert.equal(EARTHQUAKE_MARK_MAGNITUDE, 6); const rows = [ { ...row, stableId: 'small', mag: 4.5, time: 1_000 }, - { ...row, stableId: 'strong', mag: 6, time: 5_000 }, - { ...row, stableId: 'great', mag: 7.2, time: 3_000 }, + { ...row, stableId: 'strong', usgsId: 'us-strong', mag: 6, time: 5_000 }, + { + ...row, + stableId: 'great', + usgsId: 'us-great', + mag: 7.2, + place: ' 113 km SSE of Lata, Solomon Islands region of the Pacific ', + time: 3_000, + }, { ...row, stableId: 'untimed', mag: 6.5, time: null }, ]; let snapshot = rows; @@ -106,10 +113,25 @@ test('the timeline marks only strong quakes, while the layer is on', async () => await h.layer.update(h.viewer); // Every quake is drawn; only M6 and up are marks, spanning their own time. assert.equal(h.sources[0].entities.values.length, 4); + // Each labelled with its magnitude and, cut short, its place. assert.deepEqual(worldClock.coverage(), { start: 3_000, end: 5_000, marks: [3_000, 5_000], + markDetails: [ + { + time: 3_000, + label: 'M7.2 · 113 km SSE of Lata, Solomon Islands reg…', + id: 'us-great', + kind: 'earthquake', + }, + { + time: 5_000, + label: 'M6.0 · Fixture', + id: 'us-strong', + kind: 'earthquake', + }, + ], }); const told = changes; await h.layer.update(h.viewer); @@ -127,3 +149,12 @@ test('the timeline marks only strong quakes, while the layer is on', async () => assert.equal(worldClock.coverage(), null); unsubscribe(); }); + +test('a strong quake with no place is labelled with its magnitude alone', () => { + assert.equal(earthquakeMarkLabel({ mag: 6.44, place: null }), 'M6.4'); + assert.equal(earthquakeMarkLabel({ mag: 6, place: ' ' }), 'M6.0'); + assert.equal( + earthquakeMarkLabel({ mag: 8.1, place: 'Off the coast' }), + 'M8.1 · Off the coast', + ); +}); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index 2bff72090..9d75e3e8e 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -149,6 +149,9 @@ function restoreCameraView(viewer, view, landedView) { camera.setView(view); } +/** The longest name a timeline mark carries, as in the mission index. */ +const MARK_LABEL_LENGTH = 27; + export function createLifecycle({ state: layerState, services, @@ -158,18 +161,30 @@ export function createLifecycle({ const { holdContinuousRender, releaseContinuousRender } = services.render; let hover = null; // While on, the launches loaded span the time from the first to the last, - // each one a mark. Registered once; off, the layer has no time to show, and - // each step tells the clock once it has settled. + // each one a mark labelled with its mission's name (the launch's where it + // has none), cut short and in capitals as the mission index shows names. + // Registered once; off, the layer has no time to show, and each step tells + // the clock once it has settled. worldClock.register({ id: 'rocket-launches', coverage: () => { if (!layerState._enabled) return null; const marks = layerState._launches - .map((launch) => Date.parse(launch.launchTime)) - .filter(Number.isFinite) - .sort((a, b) => a - b); + .map((launch) => ({ + time: Date.parse(launch.launchTime), + label: parts.overlays + .shortMissionLabel( + launch.missionName || launch.name, + MARK_LABEL_LENGTH, + ) + .toUpperCase(), + ...(launch.id != null ? { id: String(launch.id) } : {}), + kind: 'launch', + })) + .filter(({ time }) => Number.isFinite(time)) + .sort((a, b) => a.time - b.time); if (!marks.length) return null; - return { start: marks[0], end: marks.at(-1), marks }; + return { start: marks[0].time, end: marks.at(-1).time, marks }; }, }); diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 1041f69d4..ba2544073 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -378,6 +378,7 @@ test('each product’s frames span world clock coverage, with no marks, and supp start: Date.parse(time(0)), end: Date.parse(time(10)), marks: [], + markDetails: [], }); assert.equal(world.getState().supported, true); unregister(); @@ -385,6 +386,7 @@ test('each product’s frames span world clock coverage, with no marks, and supp start: Date.parse(time(5)), end: Date.parse(time(5)), marks: [], + markDetails: [], }); clock.destroy(); assert.equal(world.coverage(), null); @@ -402,6 +404,7 @@ test('a suspended product keeps its coverage, so the way back to live stays', (t start: Date.parse(time(0)), end: Date.parse(time(10)), marks: [], + markDetails: [], }); }); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 296acbb30..5081afa67 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -597,6 +597,7 @@ test('without a clock the layer keeps one of its own on the world clock and lets start: Date.parse(times[0]), end: Date.parse(times.at(-1)), marks: [], + markDetails: [], }); h.layer.setParams({ play: true }); assert.equal(worldClock.isPlaying(), true); diff --git a/src/time/snapshotTimeline.test.mjs b/src/time/snapshotTimeline.test.mjs index a9cf52eac..6e053b611 100644 --- a/src/time/snapshotTimeline.test.mjs +++ b/src/time/snapshotTimeline.test.mjs @@ -119,6 +119,7 @@ test('at the live edge a layer with a timeline only follows and registers', asyn start: WALL - 3_600_000, end: WALL, marks: [], + markDetails: [], }); assert.equal(h.follower.active(), false); assert.deepEqual(h.calls, []); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index c600c7b3f..772252338 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -20,6 +20,49 @@ * (weather) do not drive it: they gate continuous play at their next frame. */ +/** + * A timeline mark as a layer gives it: its time in epoch ms, and optionally + * a short label to show for it, the layer's own id for it and its kind. + * @typedef {{time: number, label?: string, id?: string, kind?: string}} WorldClockMarkInput + */ + +/** + * A timeline mark as `coverage().markDetails` returns it: a finite time, and + * only those of `label`, `id` and `kind` that are non-empty strings. + * @typedef {Readonly<{time: number, label?: string, id?: string, kind?: string}>} WorldClockMark + */ + +const MARK_TEXT = ['label', 'id', 'kind']; + +/** + * A mark (a number, or `{time, label?, id?, kind?}`) in its one shape, or + * null for one with no finite time. + * @param {unknown} input + * @returns {WorldClockMark | null} + */ +function readMark(input) { + if (typeof input === 'number') + return Number.isFinite(input) ? Object.freeze({ time: input }) : null; + if (!input || typeof input !== 'object') return null; + const { time } = input; + if (typeof time !== 'number' || !Number.isFinite(time)) return null; + const mark = { time }; + for (const key of MARK_TEXT) { + const value = input[key]; + if (typeof value === 'string' && value.trim()) mark[key] = value.trim(); + } + return Object.freeze(mark); +} + +// Marks in time order; at one time, by kind, label and id, so the order +// never depends on which source registered first. +const byText = (a = '', b = '') => (a < b ? -1 : a > b ? 1 : 0); +const byMark = (a, b) => + a.time - b.time || + byText(a.kind, b.kind) || + byText(a.label, b.label) || + byText(a.id, b.id); + /** The span a JS Date can hold, either side of 1970. */ const MAX_DATE_MS = 8.64e15; @@ -372,7 +415,15 @@ export function createWorldClock({ /** * A layer's span of time it can show, for a host's timeline; null while * it has none (off, or nothing loaded). Returns the unregister. - * @param {{id: string, coverage: () => ({start: number, end: number, marks?: number[]} | null)}} source + * + * `marks` are the moments in that span worth going to (a launch, a strong + * earthquake), drawn as ticks the timeline's arrow keys step to. Each is + * an epoch ms number, or `{time, label?, id?, kind?}`: `label` is short + * text the timeline shows for it (a name, `M6.4 · a place`), `id` the + * layer's own key for the event and `kind` what sort of event it is. + * Numbers and objects may be mixed; a mark with no finite time is + * ignored, as are a label, id or kind that is not a non-empty string. + * @param {{id: string, coverage: () => ({start: number, end: number, marks?: Array} | null)}} source */ register(source) { // Each registration is its own, even under a shared id. @@ -387,20 +438,45 @@ export function createWorldClock({ coverageChanged: () => changed(), /** Whether a followed layer has time to show; see getState().supported. */ isSupported, - /** The union of the registered sources' coverage, or null with none. */ + /** + * The union of the registered sources' coverage, or null with none: + * `{start, end, marks, markDetails}`. `marks` is every mark's time, once + * each, in order, as numbers. `markDetails` is the marks themselves, in + * time order, each `{time, label?, id?, kind?}` (frozen): a mark several + * sources give alike is there once, and a bare time is dropped where a + * mark with more to say shares it. + * @returns {{start: number, end: number, marks: number[], markDetails: WorldClockMark[]} | null} + */ coverage() { let start = Infinity; let end = -Infinity; - const marks = new Set(); + const details = new Map(); for (const { source } of sources) { const span = spanOf(source); if (!span) continue; start = Math.min(start, span.start); end = Math.max(end, span.end); - for (const mark of span.marks ?? []) marks.add(mark); + for (const input of span.marks ?? []) { + const mark = readMark(input); + if (!mark) continue; + const key = JSON.stringify([ + mark.time, + mark.label, + mark.id, + mark.kind, + ]); + if (!details.has(key)) details.set(key, mark); + } } if (start > end) return null; - return { start, end, marks: [...marks].sort((a, b) => a - b) }; + const told = new Set(); + for (const mark of details.values()) + if (Object.keys(mark).length > 1) told.add(mark.time); + const markDetails = [...details.values()] + .filter((mark) => Object.keys(mark).length > 1 || !told.has(mark.time)) + .sort(byMark); + const marks = [...new Set(markDetails.map((mark) => mark.time))]; + return { start, end, marks, markDetails }; }, getState, diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index 323fa7202..0533db957 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -253,9 +253,60 @@ test('coverage is the union of the registered sources', () => { start: 50, end: 200, marks: [100, 150, 200], + markDetails: [{ time: 100 }, { time: 150 }, { time: 200 }], }); off(); - assert.deepEqual(clock.coverage(), { start: 50, end: 180, marks: [] }); + assert.deepEqual(clock.coverage(), { + start: 50, + end: 180, + marks: [], + markDetails: [], + }); +}); + +test('marks may carry a label, an id and a kind, and merge in one order', () => { + const clock = createWorldClock(fakeTime().options); + clock.register({ + id: 'launches', + coverage: () => ({ + start: 100, + end: 300, + marks: [ + { time: 300, label: ' ARTEMIS II ', id: 'a2', kind: 'launch' }, + 100, + { time: 200, label: 'M6.4 · Fiji', kind: 'earthquake', extra: 1 }, + ], + }), + }); + clock.register({ + id: 'others', + coverage: () => ({ + start: 100, + end: 300, + marks: [ + 200, + { time: 200, label: 'M6.4 · Fiji', kind: 'earthquake' }, + { time: 200, label: 'ALPHA', kind: 'launch' }, + { time: 250, label: '', id: 7, kind: null }, + { time: Number.NaN, label: 'never' }, + Number.POSITIVE_INFINITY, + null, + 'soon', + ], + }), + }); + const covered = clock.coverage(); + assert.deepEqual(covered.marks, [100, 200, 250, 300], 'the times, once each'); + assert.deepEqual(covered.markDetails, [ + { time: 100 }, + // A bare 200 says nothing a labelled mark there does not; alike marks + // from two sources are one. + { time: 200, label: 'M6.4 · Fiji', kind: 'earthquake' }, + { time: 200, label: 'ALPHA', kind: 'launch' }, + { time: 250 }, + { time: 300, label: 'ARTEMIS II', id: 'a2', kind: 'launch' }, + ]); + assert.ok(covered.markDetails.every(Object.isFrozen)); }); test('seek refuses anything but epoch ms and rate must be positive', () => { diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 72b4738dc..19184b482 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -27,7 +27,8 @@ export const WORLD_TIME_SPEEDS = Object.freeze({ * buffering, timeMs, supported, span}`, where `supported` says a layer that moves with the clock * has time to show and `span` is the stretch the timeline runs over: the * time those layers cover, reaching to now and to the world time, with their - * marks. It follows the page's world clock, which tells it of every move, and + * marks (`marks`, their times; `markDetails`, each `{time, label?, id?, + * kind?}`, as the world clock's coverage gives them). It follows the page's world clock, which tells it of every move, and * of the time a few times a second while it plays on its own. While a * timeline shows, it reads the clock each second itself, so live and the * track's end keep up with now. @@ -42,6 +43,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { start: Math.min(coverage.start, now), end: Math.max(coverage.end, now), marks: coverage.marks, + markDetails: coverage.markDetails, }; }; const spanOf = (state) => { @@ -52,6 +54,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { start: Math.min(covered.start, state.timeMs), end: Math.max(covered.end, state.timeMs), marks: covered.marks, + markDetails: covered.markDetails, }); }; const nearNow = (ms, length) => diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index f644f8543..6f6005859 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -194,6 +194,26 @@ body.cockpit-mode #world-timeline { pointer-events: none; } +/* A tick's label, shown over the time's bubble while the pointer is on the + tick (or the handle rests on its mark). It slides across itself as the + bubble does, so it stays within the track. */ +#world-timeline .world-timeline-mark-label { + position: absolute; + bottom: calc(100% + 30px); + left: calc(100% * var(--at)); + z-index: 1; + padding: 2px 8px; + border: 1px solid rgba(0, 212, 255, 0.4); + border-radius: 6px; + background: var(--glass-bg); + color: var(--text-primary); + font-size: 11px; + line-height: 1.4; + white-space: pre; + transform: translateX(calc(var(--at) * -100%)); + pointer-events: none; +} + /* Zoomed, the track drags along. */ #world-timeline .world-timeline-track.is-zoomed { cursor: grab; diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 7785e86c1..def5f43e9 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -581,3 +581,91 @@ test('a pinch measures from where the fingers are, and a lifted finger ends it', assert.ok(Math.abs(length() - zoomedTo) < 1_000); on(input, 'pointercancel', 100, 1); }); + +test('a tick shows its label on hover, and a tap by it goes to its mark', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 10 * 86_400_000; + // Ticks a fifth and seven tenths along a 1000 px track. + const launch = start + (now - start) * 0.2; + const quake = start + (now - start) * 0.7; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ + start, + end: now, + marks: [ + start, + { time: launch, label: 'GAUNTLET PAYLOAD', id: 'l1', kind: 'launch' }, + { time: quake, label: 'M6.4 · Fiji', kind: 'earthquake' }, + ], + }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + const input = bar().querySelector('input[type=range]'); + Object.defineProperty(track, 'clientWidth', { value: 1_000 }); + track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 }); + const press = (type, clientX, pointerId = 1) => + track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId })); + const tip = () => bar().querySelector('.world-timeline-mark-label'); + assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'ticks stay ticks'); + assert.equal(tip(), null); + + // Hovering within 6 px of a tick shows its label, in place of the track's hint. + press('pointermove', 205); + await nextTick(); + assert.equal(tip()?.textContent, 'GAUNTLET PAYLOAD'); + assert.equal(track.hasAttribute('title'), false); + press('pointermove', 698); + await nextTick(); + assert.equal(tip()?.textContent, 'M6.4 · Fiji'); + // Further off, or on a tick with no label, nothing shows. + press('pointermove', 690); + await nextTick(); + assert.equal(tip(), null); + assert.equal(track.hasAttribute('title'), true); + press('pointermove', 2); + await nextTick(); + assert.equal(tip(), null, 'an unlabelled mark has no label'); + press('pointermove', 205); + track.dispatchEvent(new PointerEvent('pointerleave', { clientX: 1_100, pointerId: 1 })); + await nextTick(); + assert.equal(tip(), null, 'leaving the track hides it'); + + // A tap by a tick goes to its mark exactly; elsewhere, to the time there. + press('pointerdown', 704); + press('pointerup', 704); + await nextTick(); + assert.equal(worldClock.now(), quake); + assert.match(input.getAttribute('aria-valuetext'), / · M6\.4 · Fiji$/); + press('pointerdown', 500); + press('pointerup', 500); + await nextTick(); + const middle = start + (now - start) / 2; + assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'went to the middle'); + assert.notEqual(worldClock.now(), quake); + assert.match(input.getAttribute('aria-valuetext'), /UTC$/, 'off a mark, the time alone'); + // A drag from a tick still scrubs. + press('pointerdown', 200); + press('pointermove', 300); + press('pointerup', 300); + await nextTick(); + assert.ok(Math.abs(worldClock.now() - (start + (now - start) * 0.3)) < 60_000); + + // The arrows go to the marks and tell their labels; focused, the label shows. + input.dispatchEvent(new Event('focus')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + await nextTick(); + assert.equal(worldClock.now(), launch); + assert.match(input.getAttribute('aria-valuetext'), / · GAUNTLET PAYLOAD$/); + assert.equal(tip()?.textContent, 'GAUNTLET PAYLOAD'); + input.dispatchEvent(new Event('blur')); + await nextTick(); + assert.equal(tip(), null); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 2c8e2981b..6cbede0b5 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -8,11 +8,12 @@ import UiIcon from '../../kit/UiIcon.vue'; import UiSelect from '../../kit/UiSelect.js'; // The world timeline: play and pause and the speed on the left, a track -// across the stretch the layers on can show (each launch or frame a tick) -// with a handle to scrub and the time in a bubble over it, and the way back -// to live on the right, lit while live. Scrolling or pinching the track -// zooms it around the handle, dragging it moves along it, and a double-click -// shows it all again. It shows +// across the stretch the layers on can show (each mark, a launch or a strong +// earthquake, a tick, its label shown over it on hover) with a handle to +// scrub and the time in a bubble over it, and the way back to live on the +// right, lit while live. Scrolling or pinching the track zooms it around the +// handle, dragging it moves along it, a tap on or by a tick goes to its +// mark's time exactly, and a double-click shows it all again. It shows // while a layer with a time of its own (weather, satellites, launches, // or one a host follows) is on with time to show; with only live feeds such // as flights on, the bar stays hidden. @@ -53,6 +54,8 @@ const ZOOM_STEP = 1.5; const WHEEL_ZOOM = 0.0025; // Two presses this close are a double-click. const DOUBLE_PRESS_MS = 500; +// A tap or a hover this near a tick, in pixels, is on its mark. +const MARK_HIT_PX = 6; const readout = computed(() => Number.isFinite(time.value.timeMs) @@ -212,6 +215,7 @@ let movedTime = false; const onPointerDown = (event) => { // Only the main button: a right-click is for the menu. if (event.button > 0) return; + hovered.value = null; const onHandle = event.target instanceof HTMLInputElement; pointers.set(event.pointerId, { x: event.clientX, onHandle }); if (pointers.size === 1) { @@ -241,6 +245,10 @@ const onPointerDown = (event) => { gesture = { kind: 'press', x: event.clientX, end: view.value.end }; }; const onPointerMove = (event) => { + // A pointer hovering, none pressed, shows the label of the tick under it. + hovered.value = pointers.size + ? null + : (markNear(event.clientX, event.currentTarget)?.time ?? null); const pointer = pointers.get(event.pointerId); if (!pointer) return; pointer.x = event.clientX; @@ -266,8 +274,14 @@ const onPointerMove = (event) => { }; const onPointerUp = (event) => { if (!pointers.delete(event.pointerId)) return; + // A tap on or by a tick goes to its mark exactly; elsewhere, to the time + // under it. if (gesture?.kind === 'press' && event.type === 'pointerup') - store.scrub(timeAt(event.clientX, event.currentTarget), viewLength()); + store.scrub( + markNear(event.clientX, event.currentTarget)?.time ?? + timeAt(event.clientX, event.currentTarget), + viewLength(), + ); // A finger lifted from a pinch ends it; the other one moves nothing. gesture = null; }; @@ -276,11 +290,66 @@ const place = (ms) => { const { start, end } = view.value; return end > start ? ((ms - start) / (end - start)) * 100 : 100; }; +// The labels of the marks at each time marked; a time may have none. +const markLabels = computed(() => { + const labels = new Map(); + for (const mark of span.value?.markDetails ?? []) { + if (!labels.has(mark.time)) labels.set(mark.time, []); + if (mark.label) labels.get(mark.time).push(mark.label); + } + return labels; +}); +// A tick for each time marked in view: where it sits, as a share of the +// track, and its marks' labels. const ticks = computed(() => { const { start, end } = view.value; return span.value.marks .filter((mark) => mark >= start && mark <= end) - .map((mark) => `${place(mark)}%`); + .map((mark) => ({ + time: mark, + share: place(mark) / 100, + labels: markLabels.value.get(mark) ?? [], + })); +}); +// The tick nearest `clientX` within MARK_HIT_PX of it, or null. +function markNear(clientX, track) { + if (!view.value) return null; + const { left, width } = track.getBoundingClientRect(); + if (!(width > 0)) return null; + let near = null; + let nearest = MARK_HIT_PX; + for (const tick of ticks.value) { + const off = Math.abs(left + tick.share * width - clientX); + if (off <= nearest) { + near = tick; + nearest = off; + } + } + return near; +} +// The time of the tick the pointer hovers, and whether the handle has focus. +const hovered = shallowRef(null); +const focused = shallowRef(false); +// The labels shown over the track: the hovered tick's, or with the handle +// focused and paused on a mark (an arrow key or a tap took it there), that +// mark's. Nothing for a mark with no label. +const markTip = computed(() => { + if (!view.value) return null; + const onMark = !time.value.live && focused.value ? time.value.timeMs : null; + const over = ticks.value.find((each) => each.time === hovered.value); + const tick = over ?? ticks.value.find((each) => each.time === onMark); + return tick?.labels.length + ? { share: tick.share, text: tick.labels.join('\n'), hover: Boolean(over) } + : null; +}); +// The time as the handle tells it, with the labels of the mark it is on. +const valueText = computed(() => { + const labels = time.value.live + ? null + : markLabels.value.get(time.value.timeMs); + return labels?.length + ? `${readout.value} · ${labels.join('; ')}` + : readout.value; }); const shown = computed(() => Math.min(view.value.end, Math.max(view.value.start, at.value)), @@ -294,8 +363,9 @@ const scrub = (event) => { } store.scrub(Number(event.target.value), viewLength()); }; -// Arrows go to the previous or next mark (a launch, a strong earthquake), or -// a step along the track as shown where there is none; Page keys take a tenth +// Arrows go to the previous or next mark (a launch, a strong earthquake), +// whose label the handle then tells with the time, or a step along the track +// as shown where there is none; Page keys take a tenth // of it; Home and End go to the ends of all of it. Plus and minus zoom, and // zero shows it all; with a modifier they are the browser's own zoom. const ZOOM_KEYS = ['+', '=', '-', '0']; @@ -331,17 +401,18 @@ const onKey = (event) => { >
{ @input="scrub" aria-keyshortcuts="+ - 0" @keydown="onKey" + @focus="focused = true" + @blur="focused = false" />
{{ t('Live') }}
From 620a337ce8ee9617023037a25dfdf9881f807c2a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 20:52:45 -0800 Subject: [PATCH 5/7] Weather rows play at the timeline's speed; the frame time shows under reduced motion; tidy the timeline's mark label A weather row's play ran the world clock at whatever rate it had, and going live set that back to 1, so the speed chosen on the timeline was lost. The world clock now keeps its rate while paused and live, and the timeline's speed sets it whenever it is chosen, so any play runs at it. A row's play starts from the start of the covered time when live or at its end, as the timeline's does, and does nothing while every product the row shows is suspended, as its reduced-motion note says. Live, the Weather panel's frame time is the newest of each product's frames, suspended or not, so it no longer goes away under reduced motion, on a hidden page or without an imagery host. A weather layer made without a weather clock builds its own when first enabled rather than when made, so a layer never turned on leaves the world clock alone, and its own clock (createWeatherClock({ shared: false })) leaves the world's time where it is when destroyed; only the last shared weather clock sends the world live. On the timeline, a zoom or a pan clears the hovered tick's label, a label is never wider than the track and ends in an ellipsis, and a mark's label is cut to 60 characters (WORLD_CLOCK_MARK_LABEL_LENGTH). The changelog entry says what a row's step does, names the earthquake layer's new exports and says it registers with the world clock. --- .../0000-timeline-one-control-and-marks.md | 36 ++++++--- docs/APPLICATION.md | 4 +- docs/CURRENT-STATE.md | 21 +++-- src/layers/weather/clock.js | 19 +++-- src/layers/weather/index.js | 76 +++++++++++++------ src/layers/weather/weather.test.mjs | 72 +++++++++++++++++- src/time/worldClock.js | 22 ++++-- src/time/worldClock.test.mjs | 36 ++++++++- src/ui/stores/worldTime.js | 17 +++-- src/ui/styles/world-timeline.css | 9 ++- src/ui/views/panels/WorldTimeline.test.mjs | 68 +++++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 5 +- src/ui/weatherPanel.js | 14 +++- src/ui/weatherPanel.test.mjs | 23 +++++- 14 files changed, 351 insertions(+), 71 deletions(-) diff --git a/changelog.d/0000-timeline-one-control-and-marks.md b/changelog.d/0000-timeline-one-control-and-marks.md index 6b619c3f1..3ef8a26e6 100644 --- a/changelog.d/0000-timeline-one-control-and-marks.md +++ b/changelog.d/0000-timeline-one-control-and-marks.md @@ -6,8 +6,11 @@ mark, and the handle then tells the mark's label with the time to screen readers and shows it over the track while focused. A layer's world clock coverage may give each mark as `{ time, label, id, kind }` as well as a - bare time; `worldClock.coverage()` keeps `marks` as the sorted times and - adds `markDetails`, the merged marks themselves (samehkhamis, #0000). + bare time (a label over 60 characters, `WORLD_CLOCK_MARK_LABEL_LENGTH`, is + cut, ending in `…`); `worldClock.coverage()` keeps `marks` as the sorted + times and adds `markDetails`, the merged marks themselves. A zoom or a pan clears the + hovered label, and a label is never wider than the track (samehkhamis, + #0000). - The timeline marks only events: launches, and earthquakes of magnitude 6 and up while the Earthquakes layer is on (about one every two days worldwide). It no longer draws a tick for every weather frame, which came @@ -15,18 +18,29 @@ still set the time the timeline covers, and Play still waits at each next frame until it loads. With no mark ahead, the arrow keys step along the track. The map still draws every earthquake, and an earthquake layer never - widens the timeline beyond its strong quakes' own times. A weather row's - `step` param goes to the previous or next weather frame (samehkhamis, - #0000). + widens the timeline beyond its strong quakes' own times. The Earthquakes + layer registers with the world clock (`gods-eye-view/time`) once it is + initialised, and has time to show only while on; + `gods-eye-view/layers/earthquakes` adds `EARTHQUAKE_MARK_MAGNITUDE`, + `earthquakeMagnitudeTitle`, `earthquakeMarkLabel` and `earthquakeCoverage`. + A weather row's `step` param goes to the previous or next weather frame + (samehkhamis, #0000). - 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 + alone at a past time; while live it shows the newest frame even when the + products are suspended (reduced motion, a hidden page, no imagery host). + 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` and `latest` params move the world clock as + the timeline does: `play` runs at the timeline's chosen speed, from the + start when live or at the end, and does nothing while every product the + row shows is suspended. The world clock keeps a chosen rate while paused + and live (`goLive()` no longer sets it back to 1), so any play runs at it. + A weather layer made without a weather clock builds its own when first + enabled, and destroying it leaves the world clock's time where it is + (`createWeatherClock({ shared: false })`). **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 diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 42660a10e..33036f31a 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -199,8 +199,8 @@ the unregister; call `worldClock.coverageChanged()` when that time changes. `{ start, end, marks? }` in epoch ms. `marks` are the moments worth going to, drawn as ticks the timeline's arrow keys step to and a tap on or within 6 px of snaps to. Each mark is a number, or `{ time, label?, id?, kind? }`: `label` is -short text shown when the pointer is on the tick and told with the time while -the handle is on the mark, `id` the layer's own key for the event and `kind` +short text (cut to 60 characters, ending in `…`) shown when the pointer is on +the tick and told with the time while the handle is on the mark, `id` the layer's own key for the event and `kind` what sort of event it is (`'launch'`, `'earthquake'`). The two forms may be mixed; a mark without a finite time, and a `label`, `id` or `kind` that is not a non-empty string, are ignored. `worldClock.coverage()` returns the union of diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8885629aa..98b964b54 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -8545,7 +8545,9 @@ that timeline but are not marks on it: the timeline marks only events (launches, and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys step to those, or along the track where there are none. A mark may carry a label (a launch's mission name, cut to 27 characters in capitals as the mission -index shows names; an earthquake's magnitude and place). The pointer within +index shows names; an earthquake's magnitude and place) of up to 60 characters; +a longer one ends in `…`, and a label is never wider than the track. A zoom or +a pan clears a hovered label. The pointer within 6 px of a tick shows its label over the track, and a tap there goes to the mark's time exactly; a tap elsewhere goes to the time under it and a drag scrubs. With the handle on a mark, its label follows the time in the handle's @@ -8570,14 +8572,21 @@ 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 Weather panel's frame time shows the newest frame and its age while live, -and the timeline time alone at a past time. +The Weather panel's frame time shows the newest frame and its age while live +(each product's newest, suspended products included), 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. A weather layer built without a shared -clock keeps one of its own on the page's world clock for its lifetime; it has -no frame replay of its own either way. +move that world clock as the timeline does: `play` runs at the speed chosen on +the timeline (the world clock keeps its rate while paused and live), from the +start of the covered time when live or at its end, and does nothing while +every product the row shows is suspended. A weather layer built without a +shared clock builds one of its own on the page's world clock when first +enabled (`createWeatherClock({ shared: false })`) and keeps it for its +lifetime; destroying it leaves the world's time where it is, and only the last +shared weather clock destroyed sends the world live. Neither has a frame replay +of its own. 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 diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index f46d5fc3d..547709132 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -68,12 +68,18 @@ const followers = new WeakMap(); * 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. + * + * `shared` (the default) is a clock several layers share, such as the + * catalog's: the last shared clock destroyed leaves the world live. A clock + * one layer keeps for itself (`shared: false`) leaves the world's time where + * it is. */ export function createWeatherClock({ now = Date.now, setTimeout = globalThis.setTimeout, clearTimeout = globalThis.clearTimeout, world = createWorldClock({ now }), + shared = true, } = {}) { const registry = new Map(); const listeners = new Set(); @@ -270,10 +276,13 @@ export function createWeatherClock({ stopWaiting(); world.gate(holdId, null); unfollowWorld(); - // The last weather clock leaves the world live, as a new one expects. - const left = followers.get(world) - 1; - followers.set(world, left); - if (left === 0) world.goLive(); + // The last shared weather clock leaves the world live, as a new one + // expects. + if (shared) { + 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(); @@ -340,7 +349,7 @@ export function createWeatherClock({ world.gate(holdId, next); } const unfollowWorld = world.subscribe(followWorld); - followers.set(world, (followers.get(world) ?? 0) + 1); + if (shared) followers.set(world, (followers.get(world) ?? 0) + 1); followWorld(world.getState()); return api; } diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index ce2f70c5c..6f5dfcda2 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -56,20 +56,24 @@ const utc = (value) => : t('Unavailable'); /** - * Move `world`, the world clock, as its timeline does: `play` toggles play - * (from the start of the covered time when live) and `latest` goes live. - * `step` (-1 or 1) goes to the previous or next of `frames`, the weather - * frames; the timeline does not mark them, so this is the row's own way to - * step through them. + * Move `world`, the world clock, as its timeline does: `play` toggles play at + * the world clock's rate (the speed the timeline chose), from the start of the + * covered time when live or at its end, unless `canPlay` is false; `latest` + * goes live. `step` (-1 or 1) goes to the previous or next of `frames`, the + * weather frames; the timeline does not mark them, so this is the row's own + * way to step through them. */ -function moveWorldTime(world, frames, { play, latest, step }) { +function moveWorldTime(world, frames, { play, latest, step }, canPlay) { if (!world) return; if (play === true) { const state = world.getState(); if (state.playing) world.pause(); - else { + else if (canPlay) { const covered = world.coverage(); - if (state.live && covered) world.seek(covered.start); + // The covered time reaches to now, as the timeline's track does. + const end = covered && Math.max(covered.end, world.wallNow()); + if (covered && (state.live || state.timeMs >= end)) + world.seek(Math.min(covered.start, state.timeMs)); world.play({ continuous: true }); } } @@ -90,11 +94,21 @@ function moveWorldTime(world, frames, { play, latest, step }) { } } +// What a layer that has not built its own clock yet reads: live, no frames. +const IDLE_CLOCK = Object.freeze({ + mode: 'latest', + target: null, + advancing: false, + timeline: [], + products: [], +}); + /** 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. Its frames * follow `clock`, a weather clock several layers share; without one the layer - * keeps its own on the page's world clock, for its lifetime. The feed may also + * builds its own on the page's world clock when first enabled and keeps it + * for its lifetime; that clock never sends the world live. The feed may also * name its source with `sourceName(kind)`, `kind` being 'radar', 'lightning' * or 'satellite'; NOAA nowCOAST is named otherwise. */ export function createWeatherLayer({ @@ -109,10 +123,10 @@ export function createWeatherLayer({ } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Weather requires a snapshot source'); - const ownClock = sharedClock - ? null - : createWeatherClock({ world: worldClock }); - const clock = sharedClock ?? ownClock; + // Without a shared clock the layer builds its own when first enabled, so a + // layer never turned on leaves the world clock alone. + let ownClock = null; + let clock = sharedClock ?? null; // A feed may also supply decoded frames; see weatherFrameSource. const radar = id === 'weather-radar'; const lightning = id === 'weather-lightning'; @@ -181,23 +195,31 @@ export function createWeatherLayer({ return spaced; }; const maxGap = () => spacedTimes().gap; - const isLatest = () => clock.getState().mode === 'latest'; + const clockState = () => clock?.getState() ?? IDLE_CLOCK; + const isLatest = () => clockState().mode === 'latest'; const getHost = () => imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' }; const notify = () => listener?.(); const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time); - const unsubscribeClock = clock.subscribe(() => { + const onClock = () => { if (!clock.getState().advancing || suspended()) rendering?.cancelPrefetch?.(); notify(); - }); + }; + let unsubscribeClock = clock?.subscribe(onClock) ?? null; + const ensureClock = () => { + if (clock) return; + ownClock = createWeatherClock({ world: worldClock, shared: false }); + clock = ownClock; + unsubscribeClock = clock.subscribe(onClock); + }; const observationDelayed = () => manifest?.latest && Date.now() - Date.parse(manifest.latest) > spacedTimes().delay; const suspended = () => hostHidden || documentRef?.hidden || motion?.matches; const onVisibility = () => { if (suspended()) rendering?.cancelPrefetch?.(); - void clock.refresh(); + void clock?.refresh(); notify(); }; @@ -234,7 +256,7 @@ export function createWeatherLayer({ return true; } function registerClock() { - if (unregisterClock || !rendering || !enabled) return; + if (unregisterClock || !rendering || !enabled || !clock) return; unregisterClock = clock.register({ id, get maxGapMs() { @@ -247,7 +269,7 @@ export function createWeatherLayer({ }); } async function warmNext(time) { - if (!enabled || suspended() || !clock.getState().advancing) return; + if (!enabled || suspended() || !clockState().advancing) return; // The world plays on past the newest frame, never round to the first. const times = manifest?.times ?? []; const next = times[times.indexOf(time) + 1]; @@ -297,7 +319,7 @@ export function createWeatherLayer({ if (owner === generation) { loading = Boolean(request && !request.signal.aborted); notify(); - if (isLatest()) void clock.refresh(); + if (isLatest()) void clock?.refresh(); } } } @@ -345,6 +367,7 @@ export function createWeatherLayer({ }, enable() { enabled = true; + ensureClock(); if (!removeCamera) { removeCamera = viewer.camera.moveEnd?.addEventListener(() => { checkHost(); @@ -458,7 +481,7 @@ export function createWeatherLayer({ error = null; loading = false; rendering?.clear(); - void clock.refresh(); + void clock?.refresh(); if (enabled) void layer.update(viewer); } if (infraredChanged && enabled && manifest) { @@ -492,11 +515,14 @@ export function createWeatherLayer({ // does, and every layer that follows it moves along. A step goes // through every shown observation's frames, and this row's own even // while it is suspended. + // Play does nothing while every observation it would show is suspended + // (reduced motion, a hidden page, no imagery host), as the row says. if (enabled) moveWorldTime( clock.world, [...clock.getTimeline(), ...(manifest?.times ?? [])], params, + clock.getState().products.some(({ suspended }) => !suspended), ); notify(); }, @@ -508,7 +534,7 @@ export function createWeatherLayer({ : { product, opacity }; }, getRowControls() { - const shared = clock.getState(); + const shared = clockState(); const followLatest = isLatest(); // Source-described text is data: shown as is, never translated. const display = weatherDisplay(manifest); @@ -830,7 +856,7 @@ export function createWeatherLayer({ }; }, getDiagnostics() { - const shared = clock.getState(); + const shared = clockState(); return { ...rendering?.getDiagnostics(), playing: shared.advancing, @@ -845,8 +871,10 @@ export function createWeatherLayer({ }, destroy() { layer.disable(); - unsubscribeClock(); + unsubscribeClock?.(); + unsubscribeClock = null; ownClock?.destroy(); + if (ownClock) clock = ownClock = null; removeCamera?.(); removeCamera = null; motion?.removeEventListener?.('change', onVisibility); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 5081afa67..6d83a8161 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -490,6 +490,7 @@ afterEach(() => { for (const layer of harnessed) layer.destroy(); harnessed.clear(); worldClock.goLive(); + worldClock.setRate(1); }); function layerHarness({ clock, @@ -497,6 +498,7 @@ function layerHarness({ feed, id, eventTarget = target(), + enable = true, } = {}) { const stages = []; const motion = target({ matches: reducedMotion }); @@ -570,7 +572,7 @@ function layerHarness({ createRendering: () => rendering, }); layer.init(viewer); - layer.enable(); + if (enable) layer.enable(); harnessed.add(layer); return { layer, @@ -621,6 +623,70 @@ test('without a clock the layer keeps one of its own on the world clock and lets assert.ok(h.clears() >= 1); }); +test('a layer without a clock leaves the world clock alone until enabled, and never sends it live', async (t) => { + const past = Date.parse(times[0]) - 3_600_000; + worldClock.seek(past); + const subscribe = t.mock.method(worldClock, 'subscribe'); + const gate = t.mock.method(worldClock, 'gate'); + const h = layerHarness({ enable: false }); + h.layer.getRowControls(); + h.layer.getDiagnostics(); + h.layer.setParams({ play: true, step: -1 }); + assert.equal(subscribe.mock.callCount(), 0, 'off, no clock is built'); + assert.equal(gate.mock.callCount(), 0); + assert.equal(worldClock.isPlaying(), false); + h.layer.destroy(); + harnessed.delete(h.layer); + assert.equal(worldClock.now(), past, 'never on, the world is where it was'); + assert.equal(worldClock.isLive(), false); + + const on = layerHarness({ enable: false }); + on.layer.enable(); + assert.equal(subscribe.mock.callCount(), 1, 'built on first enable'); + const update = on.layer.update(); + await flush(); + on.stages.at(-1)?.finish(); + await update; + on.layer.disable(); + on.layer.enable(); + assert.equal(subscribe.mock.callCount(), 1, 'built once'); + on.layer.destroy(); + harnessed.delete(on.layer); + assert.equal( + worldClock.isLive(), + false, + 'its own clock does not send it live', + ); + assert.equal(worldClock.now(), past); +}); + +test('a row plays at the speed the world clock keeps, from the start at the end of the covered time', async () => { + const h = layerHarness(); + const update = h.layer.update(); + await flush(); + h.stages[0].finish(); + await update; + // A speed chosen on the timeline, while live. + worldClock.setRate(3_600); + h.layer.setParams({ play: true }); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getRate(), 3_600, 'played at the speed chosen'); + assert.ok( + worldClock.now() - Date.parse(times[0]) < 3_600_000, + 'live, play starts at the start of the covered time', + ); + h.layer.setParams({ play: true }); + assert.equal(worldClock.isPlaying(), false); + // Paused at the end of the covered time (it reaches to now), play starts + // again from the start, as the timeline's does. + worldClock.seek(worldClock.wallNow() + 1_000); + h.layer.setParams({ play: true }); + assert.equal(worldClock.isPlaying(), true); + assert.ok(worldClock.now() - Date.parse(times[0]) < 3_600_000); + assert.equal(worldClock.getRate(), 3_600); + worldClock.pause(); +}); + test('reduced motion offers no play; steps still move the world clock from frame to frame', async () => { const h = layerHarness({ reducedMotion: true }); const update = h.layer.update(); @@ -631,6 +697,10 @@ test('reduced motion offers no play; steps still move the world clock from frame h.layer.getRowControls().chips.some((chip) => chip.id === 'play'), false, ); + // Play, which the row does not offer, does nothing while it is suspended. + h.layer.setParams({ play: true }); + assert.equal(worldClock.isPlaying(), false); + assert.equal(worldClock.isLive(), true); // A step back from live goes to the newest frame, and the next one back // to the frame before it. h.layer.setParams({ step: -1 }); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 772252338..17795ce1b 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -33,6 +33,8 @@ */ const MARK_TEXT = ['label', 'id', 'kind']; +/** The longest label a mark carries, ellipsis and all. */ +export const WORLD_CLOCK_MARK_LABEL_LENGTH = 60; /** * A mark (a number, or `{time, label?, id?, kind?}`) in its one shape, or @@ -51,6 +53,10 @@ function readMark(input) { const value = input[key]; if (typeof value === 'string' && value.trim()) mark[key] = value.trim(); } + if (mark.label?.length > WORLD_CLOCK_MARK_LABEL_LENGTH) + mark.label = `${mark.label + .slice(0, WORLD_CLOCK_MARK_LABEL_LENGTH - 1) + .trimEnd()}…`; return Object.freeze(mark); } @@ -235,15 +241,17 @@ export function createWorldClock({ jumps += 1; changed(); }, - /** Back to the wall clock; playback stops and its rate goes back to 1. */ + /** + * Back to the wall clock; playback stops. The rate stays as chosen, so + * the next play, from wherever it starts, runs at it. + */ goLive() { - if (live && !playing && rate === 1) return; + if (live && !playing) return; const stepping = active; live = true; cursorMs = null; playing = false; active = null; - rate = 1; jumps += 1; // Its driven(false) finds nothing left to stop. stepping?.driver.pause(); @@ -314,7 +322,10 @@ export function createWorldClock({ if (ms === null || ms === undefined) gates.delete(id); else if (isWorldTime(ms)) gates.set(id, ms); }, - /** Simulated ms per real ms for continuous playback. */ + /** + * Simulated ms per real ms for continuous playback. It holds while paused + * and live, so any play (`play()` with no rate) runs at the one chosen. + */ setRate(nextRate) { if (!Number.isFinite(nextRate) || nextRate <= 0) throw new RangeError('World clock rate must be positive'); @@ -419,7 +430,8 @@ export function createWorldClock({ * `marks` are the moments in that span worth going to (a launch, a strong * earthquake), drawn as ticks the timeline's arrow keys step to. Each is * an epoch ms number, or `{time, label?, id?, kind?}`: `label` is short - * text the timeline shows for it (a name, `M6.4 · a place`), `id` the + * text the timeline shows for it (a name, `M6.4 · a place`; one longer + * than WORLD_CLOCK_MARK_LABEL_LENGTH is cut to it, ending in `…`), `id` the * layer's own key for the event and `kind` what sort of event it is. * Numbers and objects may be mixed; a mark with no finite time is * ignored, as are a label, id or kind that is not a non-empty string. diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index 0533db957..ffa0142d7 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createWorldClock, WORLD_CLOCK_TICK_MS } from './worldClock.js'; +import { + createWorldClock, + WORLD_CLOCK_MARK_LABEL_LENGTH, + WORLD_CLOCK_TICK_MS, +} from './worldClock.js'; function fakeTime() { const t = { @@ -357,7 +361,7 @@ test('a coverage that throws counts as none', (t) => { assert.equal(clock.coverage(), null); }); -test('continuous play runs past every driver, and live resets the rate', () => { +test('continuous play runs past every driver, and live keeps the rate chosen', () => { const t = fakeTime(); const clock = createWorldClock(t.options); const calls = []; @@ -368,7 +372,13 @@ test('continuous play runs past every driver, and live resets the rate', () => { t.advance(1_000); assert.equal(clock.now(), t.wall - 61_000 + 30_000); clock.goLive(); - assert.equal(clock.getRate(), 1); + assert.equal(clock.getRate(), 30); + assert.equal(clock.now(), t.wall, 'live is the wall clock, whatever the rate'); + // Play with no rate given runs at the one chosen. + clock.seek(t.wall - 60_000); + clock.play({ continuous: true }); + t.advance(1_000); + assert.equal(clock.now(), t.wall - 61_000 + 30_000); }); test('continuous play holds while a layer waits, and goes on from there', () => { @@ -459,3 +469,23 @@ test('a seek or a return to live counts as a jump; play does not', () => { clock.goLive(); assert.equal(clock.getState().jumps, 2, 'already live is no jump'); }); + +test('a mark label longer than the timeline shows is cut, ending in an ellipsis', () => { + const clock = createWorldClock(fakeTime().options); + const long = `${'A'.repeat(58)} BEYOND THE CUT`; + clock.register({ + id: 'long', + coverage: () => ({ + start: 1, + end: 2, + marks: [ + { time: 1, label: long }, + { time: 2, label: 'B'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH) }, + ], + }), + }); + const [cut, whole] = clock.coverage().markDetails; + assert.equal(cut.label, `${'A'.repeat(58)}…`); + assert.ok(cut.label.length <= WORLD_CLOCK_MARK_LABEL_LENGTH); + assert.equal(whole.label, 'B'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH)); +}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 19184b482..0a8a335f5 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -96,7 +96,12 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { span: spanOf(state), }); const time = shallowRef(read()); - const speed = shallowRef('real'); + // The speed the world clock keeps, where it is one of those offered. + const speed = shallowRef( + Object.keys(WORLD_TIME_SPEEDS).find( + (key) => WORLD_TIME_SPEEDS[key] === worldClock.getRate(), + ) ?? 'real', + ); const rate = () => WORLD_TIME_SPEEDS[speed.value]; let ticker = null; const syncTicker = () => { @@ -141,16 +146,16 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { }, speed, /** - * 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. + * Choose how fast play runs: the world clock keeps it, so any play runs + * at it (a layer's own Play too), and 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; speed.value = next; const { playing, stepping } = worldClock.getState(); - if (!playing) return; - if (!stepping) return worldClock.setRate(rate()); + if (!playing || !stepping) return worldClock.setRate(rate()); worldClock.pause(); worldClock.play({ rate: rate(), continuous: true }); }, diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css index 6f6005859..6aabd016c 100644 --- a/src/ui/styles/world-timeline.css +++ b/src/ui/styles/world-timeline.css @@ -135,7 +135,7 @@ body.cockpit-mode #world-timeline { } } -/* The track: ticks for each launch or frame, the elapsed fill, and a native +/* The track: ticks for each launch or strong earthquake, the elapsed fill, and a native range input on top for dragging and the keyboard. */ #world-timeline .world-timeline-track { position: relative; @@ -196,9 +196,14 @@ body.cockpit-mode #world-timeline { /* A tick's label, shown over the time's bubble while the pointer is on the tick (or the handle rests on its mark). It slides across itself as the - bubble does, so it stays within the track. */ + bubble does, so it stays within the track, and is never wider than the + track: a long label ends in an ellipsis. */ #world-timeline .world-timeline-mark-label { position: absolute; + box-sizing: border-box; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; bottom: calc(100% + 30px); left: calc(100% * var(--at)); z-index: 1; diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index def5f43e9..4588a71d3 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -669,3 +669,71 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async await nextTick(); assert.equal(tip(), null); }); + +test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan clears it', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 10 * 86_400_000; + // Two ticks 8 px apart on a 1000 px track, at 200 and 208 px. + const first = start + (now - start) * 0.2; + const second = start + (now - start) * 0.208; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ + start, + end: now, + marks: [ + { time: first, label: 'FIRST', kind: 'launch' }, + { time: second, label: 'SECOND', kind: 'launch' }, + ], + }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const track = bar().querySelector('.world-timeline-track'); + Object.defineProperty(track, 'clientWidth', { value: 1_000 }); + track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 }); + const pointer = (type, clientX) => + track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId: 1 })); + const tip = () => bar().querySelector('.world-timeline-mark-label'); + // Where the first tick sits now, in px along the track. + const firstAt = () => + Number.parseFloat(bar().querySelector('.world-timeline-tick').style.left) * 10; + // The handle between them, so a zoom keeps both in view. + worldClock.seek((first + second) / 2); + await nextTick(); + pointer('pointermove', 203); + await nextTick(); + assert.equal(tip()?.textContent, 'FIRST'); + pointer('pointermove', 205); + await nextTick(); + assert.equal(tip()?.textContent, 'SECOND', 'the nearer of the two'); + + // A wheel zoom moves the ticks from under the pointer: the label goes. + track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true })); + await nextTick(); + assert.equal(track.classList.contains('is-zoomed'), true); + assert.equal(tip(), null, 'a zoom clears the label'); + // So does a pan, by wheel or by drag. + pointer('pointermove', firstAt()); + await nextTick(); + assert.equal(tip()?.textContent, 'FIRST'); + track.dispatchEvent( + new WheelEvent('wheel', { deltaY: 40, shiftKey: true, cancelable: true }), + ); + await nextTick(); + assert.equal(tip(), null, 'a pan clears the label'); + + // A tap between them goes to the nearer mark. + track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + await nextTick(); + assert.equal(track.classList.contains('is-zoomed'), false); + pointer('pointerdown', 205); + pointer('pointerup', 205); + await nextTick(); + assert.equal(worldClock.now(), second, 'a tap goes to the nearer mark'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 6cbede0b5..054e4370d 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -112,8 +112,10 @@ const view = computed(() => { }); const zoomed = computed(() => zoom.value !== null); const viewLength = () => view.value.end - view.value.start; -// Show `length` of the span ending at `viewEnd`, held to the span. +// Show `length` of the span ending at `viewEnd`, held to the span. A zoom +// or a pan moves the ticks from under the pointer, so its label goes. function showView(length, viewEnd) { + hovered.value = null; const { start, end } = span.value; if (length >= end - start) { showWhole(); @@ -141,6 +143,7 @@ const follow = (ms) => { }; const offView = (ms) => ms < view.value.start || ms > view.value.end; const showWhole = () => { + hovered.value = null; zoom.value = null; panned = false; }; diff --git a/src/ui/weatherPanel.js b/src/ui/weatherPanel.js index 7285ed76b..3ee6c4be7 100644 --- a/src/ui/weatherPanel.js +++ b/src/ui/weatherPanel.js @@ -118,9 +118,17 @@ export function createWeatherPanel({ 'weather-lightning': t('Lightning density'), }; // 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; + // time alone. The world timeline moves the time. Live, each product's + // newest frame counts, suspended or not (reduced motion, a hidden page), + // as the timeline leaves those out. + const newest = () => { + const times = (state.products ?? []) + .map(({ selected }) => selected) + .filter((time) => Number.isFinite(Date.parse(time))) + .sort((a, b) => Date.parse(a) - Date.parse(b)); + return times.at(-1) ?? state.timeline.at(-1); + }; + const shownTime = state.mode === 'latest' ? newest() : state.target; const frameTime = showObserved && shownTime ? state.mode === 'history' diff --git a/src/ui/weatherPanel.test.mjs b/src/ui/weatherPanel.test.mjs index c8c032741..c12292214 100644 --- a/src/ui/weatherPanel.test.mjs +++ b/src/ui/weatherPanel.test.mjs @@ -86,10 +86,29 @@ test('the frame time shows with any observed product; the panel has no time cont view.update([wind, radar]); assert.equal(frameTime(f).hidden, false); assert.equal(frameTime(f).textContent, '01:05 UTC · 12 min ago'); - f.state({ timeline: [ticks[0]] }); + f.state({ + timeline: [ticks[0]], + products: [{ id: radar.id, shown: ticks[0], selected: ticks[0] }], + }); assert.equal(frameTime(f).textContent, '01:00 UTC · 17 min ago'); - f.state({ timeline: [] }); + f.state({ timeline: [], products: [] }); assert.equal(frameTime(f).hidden, true); + // Suspended products (reduced motion, a hidden page) leave the timeline; + // live, their newest frame still shows. + f.state({ + timeline: [], + products: [ + { id: radar.id, shown: ticks[1], selected: ticks[1], suspended: true }, + { + id: 'weather-lightning', + shown: null, + selected: ticks[0], + suspended: true, + }, + ], + }); + assert.equal(frameTime(f).hidden, false); + assert.equal(frameTime(f).textContent, '01:05 UTC · 12 min ago'); f.state({ timeline: ticks, mode: 'history', target: ticks[0] }); assert.equal(frameTime(f).textContent, '01:00 UTC'); assert.equal( From 1dc2dbe4ea50c36de80b22eec51720d34fe79038 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 20:55:43 -0800 Subject: [PATCH 6/7] Name the changelog fragment for #1745 --- ...-and-marks.md => 1745-timeline-one-control-and-marks.md} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename changelog.d/{0000-timeline-one-control-and-marks.md => 1745-timeline-one-control-and-marks.md} (97%) diff --git a/changelog.d/0000-timeline-one-control-and-marks.md b/changelog.d/1745-timeline-one-control-and-marks.md similarity index 97% rename from changelog.d/0000-timeline-one-control-and-marks.md rename to changelog.d/1745-timeline-one-control-and-marks.md index 3ef8a26e6..3915b6087 100644 --- a/changelog.d/0000-timeline-one-control-and-marks.md +++ b/changelog.d/1745-timeline-one-control-and-marks.md @@ -10,7 +10,7 @@ cut, ending in `…`); `worldClock.coverage()` keeps `marks` as the sorted times and adds `markDetails`, the merged marks themselves. A zoom or a pan clears the hovered label, and a label is never wider than the track (samehkhamis, - #0000). + #1745). - The timeline marks only events: launches, and earthquakes of magnitude 6 and up while the Earthquakes layer is on (about one every two days worldwide). It no longer draws a tick for every weather frame, which came @@ -24,7 +24,7 @@ `gods-eye-view/layers/earthquakes` adds `EARTHQUAKE_MARK_MAGNITUDE`, `earthquakeMagnitudeTitle`, `earthquakeMarkLabel` and `earthquakeCoverage`. A weather row's `step` param goes to the previous or next weather frame - (samehkhamis, #0000). + (samehkhamis, #1745). - 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 @@ -45,4 +45,4 @@ `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). + also `weatherClock.world`) instead (samehkhamis, #1745). From ac04812db200f58278eb86bd5f2d6dd6e82863a0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 21:12:43 -0800 Subject: [PATCH 7/7] A row plays only with a timeline to play; the speed shown follows the world clock; mark labels merge whole and cut by character A weather row's play started the world clock even with no time to show, where there is no timeline; it now does nothing then, as the timeline's own Play cannot be reached. A weather layer made without a weather clock does nothing once destroyed, so enabling it after builds no clock. The timeline's speed follows the world clock's rate whoever set it, and its Play runs at that rate, so it and a row's play never run at different rates; a rate not offered shows as the nearest offered speed. Choosing a speed sets the clock's rate first, also while a driver steps. Marks merge on their whole label and are cut for showing after, so two whose labels only differ past the cut stay two, and the cut counts graphemes (code points where the runtime cannot tell them), so an emoji is never split. Tests that compared a DOM element with null built an assertion message from the element when they failed, which hung the run; they compare plain values now, so they fail at once. The timeline tests put the clock back to real time after each. --- .../1745-timeline-one-control-and-marks.md | 23 ++++---- docs/CURRENT-STATE.md | 17 ++++-- .../screenAnnotationRenderer.test.mjs | 4 +- src/cockpitSignalFocus.test.mjs | 4 +- src/firstRunLauncherView.test.mjs | 4 +- src/layers/weather/index.js | 25 +++++--- src/layers/weather/weather.test.mjs | 25 ++++++++ .../dataPacks/presentationView.test.mjs | 6 +- src/time/worldClock.js | 39 ++++++++++-- src/time/worldClock.test.mjs | 45 ++++++++++++++ src/ui/awarenessPanel.test.mjs | 6 +- src/ui/cockpitBridge.test.mjs | 6 +- src/ui/cockpitOrbital.test.mjs | 16 ++--- src/ui/commandDock.test.mjs | 2 +- src/ui/layerFinder.test.mjs | 4 +- src/ui/localePickerView.test.mjs | 4 +- src/ui/newsPanel.test.mjs | 22 +++---- src/ui/panelChromeButtons.test.mjs | 4 +- src/ui/providerSettings.test.mjs | 10 ++-- src/ui/quebecCctvOverlay.test.mjs | 2 +- src/ui/renderFailureView.test.mjs | 4 +- src/ui/stores/worldTime.js | 35 +++++++---- src/ui/views/mountApplicationViews.test.mjs | 4 +- src/ui/views/panels/DrawMarkList.test.mjs | 2 +- src/ui/views/panels/WorldTimeline.test.mjs | 59 +++++++++++++++---- src/ui/wildlifeSightingCard.test.mjs | 10 ++-- 26 files changed, 274 insertions(+), 108 deletions(-) diff --git a/changelog.d/1745-timeline-one-control-and-marks.md b/changelog.d/1745-timeline-one-control-and-marks.md index 3915b6087..2585b4760 100644 --- a/changelog.d/1745-timeline-one-control-and-marks.md +++ b/changelog.d/1745-timeline-one-control-and-marks.md @@ -6,11 +6,12 @@ mark, and the handle then tells the mark's label with the time to screen readers and shows it over the track while focused. A layer's world clock coverage may give each mark as `{ time, label, id, kind }` as well as a - bare time (a label over 60 characters, `WORLD_CLOCK_MARK_LABEL_LENGTH`, is - cut, ending in `…`); `worldClock.coverage()` keeps `marks` as the sorted - times and adds `markDetails`, the merged marks themselves. A zoom or a pan clears the - hovered label, and a label is never wider than the track (samehkhamis, - #1745). + bare time. `worldClock.coverage()` keeps `marks` as the sorted times and + adds `markDetails`, the merged marks themselves, merged on their whole + label; a label longer than `WORLD_CLOCK_MARK_LABEL_LENGTH` (60 characters, + exported from `gods-eye-view/time`) is then cut, ending in `…`, never + splitting an emoji. A zoom or a pan clears the hovered label, and a label + is never wider than the track (samehkhamis, #1745). - The timeline marks only events: launches, and earthquakes of magnitude 6 and up while the Earthquakes layer is on (about one every two days worldwide). It no longer draws a tick for every weather frame, which came @@ -36,11 +37,13 @@ first. A weather row's `play` and `latest` params move the world clock as the timeline does: `play` runs at the timeline's chosen speed, from the start when live or at the end, and does nothing while every product the - row shows is suspended. The world clock keeps a chosen rate while paused - and live (`goLive()` no longer sets it back to 1), so any play runs at it. - A weather layer made without a weather clock builds its own when first - enabled, and destroying it leaves the world clock's time where it is - (`createWeatherClock({ shared: false })`). **Breaking:** the catalog's + row shows is suspended or while there is no time to show. The world clock + keeps a chosen rate while paused and live (`goLive()` no longer sets it + back to 1), so any play runs at it, and the timeline's speed shows the + clock's rate whoever set it (the nearest offered speed for another rate). + A weather layer made without a weather clock leaves the world clock alone + until it is first enabled, does nothing once destroyed, and destroying it + leaves the world clock's time where it is. **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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 98b964b54..2ba3165f0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -8545,8 +8545,10 @@ that timeline but are not marks on it: the timeline marks only events (launches, and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys step to those, or along the track where there are none. A mark may carry a label (a launch's mission name, cut to 27 characters in capitals as the mission -index shows names; an earthquake's magnitude and place) of up to 60 characters; -a longer one ends in `…`, and a label is never wider than the track. A zoom or +index shows names; an earthquake's magnitude and place). Marks merge on their +whole label; one longer than 60 characters (graphemes, so an emoji is never +split) then shows cut, ending in `…`, and a label is never wider than the +track. A zoom or a pan clears a hovered label. The pointer within 6 px of a tick shows its label over the track, and a tap there goes to the mark's time exactly; a tap elsewhere goes to the time under it and a drag @@ -8579,12 +8581,15 @@ 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: `play` runs at the speed chosen on -the timeline (the world clock keeps its rate while paused and live), from the -start of the covered time when live or at its end, and does nothing while -every product the row shows is suspended. A weather layer built without a +the timeline (the world clock keeps its rate while paused and live, and the +timeline's speed selector shows the clock's rate, or the nearest offered speed +for a rate a host set), from the start of the covered time when live or at its +end, and does nothing while every product the row shows is suspended or while +there is no covered time. A weather layer built without a shared clock builds one of its own on the page's world clock when first enabled (`createWeatherClock({ shared: false })`) and keeps it for its -lifetime; destroying it leaves the world's time where it is, and only the last +lifetime; destroying it leaves the world's time where it is, and enabling it +after that does nothing, and only the last shared weather clock destroyed sends the world live. Neither has a frame replay of its own. diff --git a/src/annotations/screenAnnotationRenderer.test.mjs b/src/annotations/screenAnnotationRenderer.test.mjs index ce1fb4058..00188c527 100644 --- a/src/annotations/screenAnnotationRenderer.test.mjs +++ b/src/annotations/screenAnnotationRenderer.test.mjs @@ -216,7 +216,7 @@ test('outline upgrade preserves the existing SVG group identity', async (t) => { assert.ok(after === before, 'the rendered group is mutated, not replaced'); assert.deepEqual(sampledAnchors.at(-1), [-97.5, 31.2], 'the existing group projects from the re-seated anchor'); assert.equal(groups().length, 1); - assert.equal(after.querySelector('.gev-anno-ring'), null, 'reticle rings are removed in place'); + assert.equal(after.querySelector('.gev-anno-ring') === null, true, 'reticle rings are removed in place'); assert.ok(after.querySelector('.gev-anno-callout') === callout, 'the original callout remains in the group'); assert.ok(after.querySelector('.gev-anno-dot') === dot, 'the anchor dot remains in the group'); assert.ok(svg()); @@ -330,7 +330,7 @@ test('a removed mark fades out, then leaves the board', async (t) => { assert.equal(group.isConnected, false); renderer.destroy(); await settle(); - assert.equal(document.querySelector('.gev-screen-whiteboard'), null); + assert.equal(document.querySelector('.gev-screen-whiteboard') === null, true); }); // ── Partial-add unwind (review round 2) ─────────────────────────────────────── diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs index 89ee1cac1..009ebfbf9 100644 --- a/src/cockpitSignalFocus.test.mjs +++ b/src/cockpitSignalFocus.test.mjs @@ -209,7 +209,7 @@ test('rows render the signals the controller presents', async (t) => { ); assert.equal(status.className, 'warning'); assert.equal(status.querySelector('div > strong').textContent, 'UNKNOWN'); - assert.equal(status.querySelector('button'), null); + assert.equal(status.querySelector('button') === null, true); }); test('actual Context refresh retains the focused signal button across unchanged and changed details', async (t) => { @@ -315,7 +315,7 @@ test('removing the focused identity uses the shown briefing tab once and permits assert.equal(document.activeElement === f.signalTab(), true); assert.equal(f.focusCalls(), calls + 1); assert.equal(f.losses(), 0); - assert.equal(f.signalTab().closest('[hidden]'), null); + assert.equal(f.signalTab().closest('[hidden]') === null, true); // Traversal carries on forward from the footer tab. f.tab(); assert.equal(document.activeElement === f.pageTab(1), true); diff --git a/src/firstRunLauncherView.test.mjs b/src/firstRunLauncherView.test.mjs index 633d9de30..16c76e1d9 100644 --- a/src/firstRunLauncherView.test.mjs +++ b/src/firstRunLauncherView.test.mjs @@ -165,7 +165,7 @@ test('the DISMISS button closes the card the way ESC does', async (t) => { assert.notEqual(sessionStorageRef.read(), null, 'dismissed for the session'); root.dispatchEvent(new Event('transitionend')); await nextTick(); - assert.equal(document.getElementById('first-run-launcher'), null); + assert.equal(document.getElementById('first-run-launcher') === null, true); }); test('the scroll fade clears when the tiles fit after a resize', async (t) => { @@ -191,5 +191,5 @@ test('ESC closes the card, returns focus, and removes it without a transition', // Reduced motion: no transitionend, so the bounded fallback removes it. t.mock.timers.tick(400); await nextTick(); - assert.equal(document.getElementById('first-run-launcher'), null); + assert.equal(document.getElementById('first-run-launcher') === null, true); }); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 6f5dfcda2..ec6fea7f0 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -58,8 +58,8 @@ const utc = (value) => /** * Move `world`, the world clock, as its timeline does: `play` toggles play at * the world clock's rate (the speed the timeline chose), from the start of the - * covered time when live or at its end, unless `canPlay` is false; `latest` - * goes live. `step` (-1 or 1) goes to the previous or next of `frames`, the + * covered time when live or at its end, unless `canPlay` is false or there + * is no covered time (no timeline to play); `latest` goes live. `step` (-1 or 1) goes to the previous or next of `frames`, the * weather frames; the timeline does not mark them, so this is the row's own * way to step through them. */ @@ -68,13 +68,16 @@ function moveWorldTime(world, frames, { play, latest, step }, canPlay) { if (play === true) { const state = world.getState(); if (state.playing) world.pause(); - else if (canPlay) { - const covered = world.coverage(); - // The covered time reaches to now, as the timeline's track does. - const end = covered && Math.max(covered.end, world.wallNow()); - if (covered && (state.live || state.timeMs >= end)) - world.seek(Math.min(covered.start, state.timeMs)); - world.play({ continuous: true }); + else { + // With no time to show there is no timeline, and nothing to play. + const covered = canPlay && state.supported ? world.coverage() : null; + if (covered) { + // The covered time reaches to now, as the timeline's track does. + const end = Math.max(covered.end, world.wallNow()); + if (state.live || state.timeMs >= end) + world.seek(Math.min(covered.start, state.timeMs)); + world.play({ continuous: true }); + } } } if (latest === true) world.goLive(); @@ -153,6 +156,7 @@ export function createWeatherLayer({ manifest = null, request = null, listener = null; + let destroyed = false; let enabled = false, loading = false; let generation = 0, @@ -366,6 +370,8 @@ export function createWeatherLayer({ notify(); }, enable() { + // Destroyed, the layer stays off: no clock is built for it again. + if (destroyed) return; enabled = true; ensureClock(); if (!removeCamera) { @@ -870,6 +876,7 @@ export function createWeatherLayer({ }; }, destroy() { + destroyed = true; layer.disable(); unsubscribeClock?.(); unsubscribeClock = null; diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 6d83a8161..309b51165 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -660,6 +660,31 @@ test('a layer without a clock leaves the world clock alone until enabled, and ne assert.equal(worldClock.now(), past); }); +test('destroyed, a layer without a clock stays off: enabling it builds no clock', (t) => { + const subscribe = t.mock.method(worldClock, 'subscribe'); + const h = layerHarness(); + assert.equal(subscribe.mock.callCount(), 1, 'its clock, built on enable'); + h.layer.destroy(); + harnessed.delete(h.layer); + h.layer.enable(); + assert.equal(subscribe.mock.callCount(), 1, 'no clock built after destroy'); + assert.equal(worldClock.follows('weather-radar'), false); + h.layer.setParams({ play: true }); + assert.equal(worldClock.isPlaying(), false); +}); + +test('with no frames yet there is no timeline, and a row plays nothing', () => { + worldClock.setRate(604_800); + const h = layerHarness({ + feed: { getSnapshot: () => new Promise(() => {}) }, + }); + void h.layer.update(); + assert.equal(worldClock.coverage(), null); + h.layer.setParams({ play: true }); + assert.equal(worldClock.isLive(), true); + assert.equal(worldClock.isPlaying(), false); +}); + test('a row plays at the speed the world clock keeps, from the start at the end of the covered time', async () => { const h = layerHarness(); const update = h.layer.update(); diff --git a/src/scenes/dataPacks/presentationView.test.mjs b/src/scenes/dataPacks/presentationView.test.mjs index c6c173d89..73b38877a 100644 --- a/src/scenes/dataPacks/presentationView.test.mjs +++ b/src/scenes/dataPacks/presentationView.test.mjs @@ -66,7 +66,11 @@ test('a loaded pack shows its attribution card in the viewer container', async ( card.querySelector('div').textContent, 'clip · Synthetic · CC0-1.0', ); - assert.equal(card.querySelector('a'), null, 'no link without a source URL'); + assert.equal( + card.querySelector('a') === null, + true, + 'no link without a source URL', + ); const video = card.querySelector('video'); assert.equal(video.controls, true); assert.equal(video.preload, 'none'); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 17795ce1b..9beff2d93 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -33,9 +33,37 @@ */ const MARK_TEXT = ['label', 'id', 'kind']; -/** The longest label a mark carries, ellipsis and all. */ +/** + * The longest label a mark carries, ellipsis and all, in characters as a + * reader sees them (graphemes where the runtime can tell them, otherwise code + * points), so an emoji or an accented letter is never split. + */ export const WORLD_CLOCK_MARK_LABEL_LENGTH = 60; +const graphemes = + typeof Intl?.Segmenter === 'function' + ? (() => { + const segmenter = new Intl.Segmenter(undefined, { + granularity: 'grapheme', + }); + return (text) => + Array.from(segmenter.segment(text), ({ segment }) => segment); + })() + : (text) => Array.from(text); + +/** `mark` with its label cut to WORLD_CLOCK_MARK_LABEL_LENGTH, ending in `…`. */ +function shownMark(mark) { + if (!mark.label || mark.label.length <= WORLD_CLOCK_MARK_LABEL_LENGTH) + return mark; + const parts = graphemes(mark.label); + if (parts.length <= WORLD_CLOCK_MARK_LABEL_LENGTH) return mark; + const label = `${parts + .slice(0, WORLD_CLOCK_MARK_LABEL_LENGTH - 1) + .join('') + .trimEnd()}…`; + return Object.freeze({ ...mark, label }); +} + /** * A mark (a number, or `{time, label?, id?, kind?}`) in its one shape, or * null for one with no finite time. @@ -53,10 +81,6 @@ function readMark(input) { const value = input[key]; if (typeof value === 'string' && value.trim()) mark[key] = value.trim(); } - if (mark.label?.length > WORLD_CLOCK_MARK_LABEL_LENGTH) - mark.label = `${mark.label - .slice(0, WORLD_CLOCK_MARK_LABEL_LENGTH - 1) - .trimEnd()}…`; return Object.freeze(mark); } @@ -484,9 +508,12 @@ export function createWorldClock({ const told = new Set(); for (const mark of details.values()) if (Object.keys(mark).length > 1) told.add(mark.time); + // Marks merge on their whole label, and are cut for showing after, so + // two whose labels only differ past the cut stay two. const markDetails = [...details.values()] .filter((mark) => Object.keys(mark).length > 1 || !told.has(mark.time)) - .sort(byMark); + .sort(byMark) + .map(shownMark); const marks = [...new Set(markDetails.map((mark) => mark.time))]; return { start, end, marks, markDetails }; }, diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index ffa0142d7..8c0cbe5b8 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -489,3 +489,48 @@ test('a mark label longer than the timeline shows is cut, ending in an ellipsis' assert.ok(cut.label.length <= WORLD_CLOCK_MARK_LABEL_LENGTH); assert.equal(whole.label, 'B'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH)); }); + +test('a cut label never splits an emoji, and counts it as one character', () => { + const clock = createWorldClock(fakeTime().options); + const a = 'A'.repeat(58); + clock.register({ + id: 'emoji', + coverage: () => ({ + start: 1, + end: 3, + marks: [ + { time: 1, label: `${a}🚀 LIFTOFF` }, + { time: 2, label: `${a}👩‍🚀 CREW` }, + // Sixty characters, though more UTF-16 units: whole. + { time: 3, label: `${'A'.repeat(59)}🚀` }, + ], + }), + }); + const [rocket, crew, whole] = clock.coverage().markDetails; + assert.equal(rocket.label, `${a}🚀…`); + assert.equal(crew.label, `${a}👩‍🚀…`); + assert.equal(whole.label, `${'A'.repeat(59)}🚀`); + for (const { label } of [rocket, crew, whole]) + assert.equal(label.isWellFormed(), true, 'no lone surrogate'); +}); + +test('marks merge on their whole label, so labels alike up to the cut stay two', () => { + const clock = createWorldClock(fakeTime().options); + const stem = 'C'.repeat(WORLD_CLOCK_MARK_LABEL_LENGTH - 1); + for (const id of ['one', 'two']) + clock.register({ + id, + coverage: () => ({ + start: 5, + end: 5, + marks: [{ time: 5, label: `${stem}${id === 'one' ? ' FIRST' : ' SECOND'}` }], + }), + }); + const covered = clock.coverage(); + assert.deepEqual(covered.marks, [5]); + assert.equal(covered.markDetails.length, 2); + for (const mark of covered.markDetails) { + assert.equal(mark.label, `${stem}…`); + assert.equal(Object.isFrozen(mark), true); + } +}); diff --git a/src/ui/awarenessPanel.test.mjs b/src/ui/awarenessPanel.test.mjs index 53cf0ad94..f68fb8515 100644 --- a/src/ui/awarenessPanel.test.mjs +++ b/src/ui/awarenessPanel.test.mjs @@ -280,7 +280,7 @@ test('a tracked flight’s photo shows under its name, credited and linked to it test('no photo renders without one', async (t) => { const { panel, publish } = fixture(t); await publish({ ...results(), photo: null }); - assert.equal(panel().querySelector('.military-awareness-photo'), null); + assert.equal(panel().querySelector('.military-awareness-photo') === null, true); }); test('a photo whose image fails to load is hidden until another arrives', async (t) => { @@ -290,10 +290,10 @@ test('a photo whose image fails to load is hidden until another arrives', async .querySelector('.military-awareness-photo img') .dispatchEvent(new Event('error')); await nextTick(); - assert.equal(panel().querySelector('.military-awareness-photo'), null); + assert.equal(panel().querySelector('.military-awareness-photo') === null, true); // A repaint with the same photo keeps it hidden. await publish({ ...results({ previousDisabled: false }), photo: photo() }); - assert.equal(panel().querySelector('.military-awareness-photo'), null); + assert.equal(panel().querySelector('.military-awareness-photo') === null, true); // Another plane's photo shows. const next = photo({ src: 'https://t.plnspttrs.net/2/def456_280.jpg' }); await publish({ ...results(), photo: next }); diff --git a/src/ui/cockpitBridge.test.mjs b/src/ui/cockpitBridge.test.mjs index b58e53684..e6c038d7d 100644 --- a/src/ui/cockpitBridge.test.mjs +++ b/src/ui/cockpitBridge.test.mjs @@ -839,7 +839,7 @@ test('arrows do nothing new in an aircraft cockpit', async (t) => { near(after.pitchDeg, before.pitchDeg, 'the aircraft pitch', 1e-6); assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); assert.equal(store.instruments.look.offset, false); - assert.equal(document.querySelector('.cockpit-look'), null); + assert.equal(document.querySelector('.cockpit-look') === null, true); // The tape stays on the aircraft's heading, with no bow or stern marks. const centre = store.instruments.compass.find( (division) => division.slot === 0, @@ -849,7 +849,7 @@ test('arrows do nothing new in an aircraft cockpit', async (t) => { prograde: null, retrograde: null, }); - assert.equal(document.querySelector('.cockpit-compass-marker'), null); + assert.equal(document.querySelector('.cockpit-compass-marker') === null, true); assert.doesNotMatch( document.querySelector('.cockpit-help').textContent, /ARROWS LOOK/, @@ -1248,7 +1248,7 @@ test('a ship with neither heading nor course makes up no direction', async (t) = prograde: null, retrograde: null, }); - assert.equal(document.querySelector('.cockpit-compass-marker'), null); + assert.equal(document.querySelector('.cockpit-compass-marker') === null, true); assert.match( $('.cockpit-visor-status-right').textContent, /NO HEADING · AIS/, diff --git a/src/ui/cockpitOrbital.test.mjs b/src/ui/cockpitOrbital.test.mjs index 987527c28..7b3ad959e 100644 --- a/src/ui/cockpitOrbital.test.mjs +++ b/src/ui/cockpitOrbital.test.mjs @@ -45,7 +45,7 @@ function fixture(t) { test('the orbital HUD is hidden until a satellite view is published', async (t) => { const f = fixture(t); assert.equal(f.section().hidden, true); - assert.equal(f.section().querySelector('#cockpit-orbital-zoom-in'), null); + assert.equal(f.section().querySelector('#cockpit-orbital-zoom-in') === null, true); }); test('a published orbital view renders its readouts and zoom controls', async (t) => { @@ -97,7 +97,7 @@ test('an unknown velocity leaves its row out', async (t) => { const f = fixture(t); f.store.publishOrbital({ ...VIEW, velocityMps: null }); await nextTick(); - assert.equal(f.section().querySelector('.cockpit-orbital-velocity'), null); + assert.equal(f.section().querySelector('.cockpit-orbital-velocity') === null, true); assert.equal(f.text('.cockpit-orbital-altitude'), '418 KM'); }); @@ -145,7 +145,7 @@ test('publishing null hides the orbital HUD again', async (t) => { f.store.publishOrbital(null); await nextTick(); assert.equal(f.section().hidden, true); - assert.equal(f.section().querySelector('.cockpit-orbital-name'), null); + assert.equal(f.section().querySelector('.cockpit-orbital-name') === null, true); }); const ISS_TLE = Object.freeze({ @@ -160,7 +160,7 @@ test('the place below shows at the top once known', async (t) => { const f = fixture(t); f.store.publishOrbital({ ...VIEW, place: undefined }); await nextTick(); - assert.equal(f.section().querySelector('#cockpit-orbital-over'), null); + assert.equal(f.section().querySelector('#cockpit-orbital-over') === null, true); const over = () => f.section().querySelector('#cockpit-orbital-over strong')?.textContent; f.store.publishOrbital({ @@ -195,7 +195,7 @@ test('the place below shows at the top once known', async (t) => { // says nothing rather than call land open ocean. f.store.publishOrbital({ ...VIEW, place: null }); await nextTick(); - assert.equal(document.querySelector('#cockpit-orbital-over'), null); + assert.equal(document.querySelector('#cockpit-orbital-over') === null, true); }); test('an owner country shows its flag from the sheet, or its code if the sheet will not load', async (t) => { @@ -216,7 +216,7 @@ test('an owner country shows its flag from the sheet, or its code if the sheet w owner: { countryCode: 'US', label: 'USA' }, }); await nextTick(); - assert.equal(f.section().querySelector('.cockpit-orbital-flag'), null); + assert.equal(f.section().querySelector('.cockpit-orbital-flag') === null, true); assert.equal(images.length, 1, 'the sheet is asked for'); images[0].onerror(); await settle(); @@ -244,7 +244,7 @@ test('an owner country shows its flag from the sheet, or its code if the sheet w flag.style.backgroundPosition, ownerFlagStyle('FR').backgroundPosition, ); - assert.equal(f.section().querySelector('.cockpit-orbital-owner-badge'), null); + assert.equal(f.section().querySelector('.cockpit-orbital-owner-badge') === null, true); }); test('the readout names the owner, operator and element set', async (t) => { @@ -327,7 +327,7 @@ test('unknown identity fields leave their rows out', async (t) => { '.cockpit-orbital-ident', '#cockpit-orbital-tle', ]) - assert.equal(f.section().querySelector(selector), null, selector); + assert.equal(f.section().querySelector(selector) === null, true, selector); // An element set with no NORAD id still dates itself. f.store.publishOrbital({ ...VIEW, tle: ISS_TLE }); await nextTick(); diff --git a/src/ui/commandDock.test.mjs b/src/ui/commandDock.test.mjs index c70007a17..733c34425 100644 --- a/src/ui/commandDock.test.mjs +++ b/src/ui/commandDock.test.mjs @@ -142,7 +142,7 @@ test('the map source row renders the published chips and selects through the con assert.equal(osm.classList.contains('active'), true); assert.equal(osm.getAttribute('aria-pressed'), 'true'); assert.equal(osm.hasAttribute('aria-label'), false); - assert.equal(esri.querySelector('.map-stack-chip-req'), null); + assert.equal(esri.querySelector('.map-stack-chip-req') === null, true); bing.click(); esri.click(); assert.deepEqual(selected, ['esri-imagery']); diff --git a/src/ui/layerFinder.test.mjs b/src/ui/layerFinder.test.mjs index 8cd61ff1a..b93821cb3 100644 --- a/src/ui/layerFinder.test.mjs +++ b/src/ui/layerFinder.test.mjs @@ -146,7 +146,7 @@ test('Escape in the search field closes it without reaching the panel', async (t ); await flush(); assert.equal(reached, false); - assert.equal(document.getElementById('layer-finder-input'), null); + assert.equal(document.getElementById('layer-finder-input') === null, true); assert.equal(document.activeElement?.id, 'layer-finder-search-btn'); }); @@ -167,7 +167,7 @@ test('the filter menu takes focus, moves with the arrows and closes on Escape', new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), ); await flush(); - assert.equal(document.getElementById('layer-finder-menu'), null); + assert.equal(document.getElementById('layer-finder-menu') === null, true); assert.equal( document.activeElement, document.querySelector('[aria-controls="layer-finder-menu"]'), diff --git a/src/ui/localePickerView.test.mjs b/src/ui/localePickerView.test.mjs index a047178da..430cdd5b2 100644 --- a/src/ui/localePickerView.test.mjs +++ b/src/ui/localePickerView.test.mjs @@ -340,12 +340,12 @@ test('the provider settings corner shows the picker only while the shell publish app.mount(host); t.after(() => app.unmount()); const store = useLocaleStore(pinia); - assert.equal(document.getElementById('locale-picker'), null); + assert.equal(document.getElementById('locale-picker') === null, true); store.publishPicker({ current: 'de', onSelect() {} }); await nextTick(); const code = document.querySelector('#corner-actions .locale-picker-code'); assert.equal(code?.textContent, 'DE'); store.publishPicker(null); await nextTick(); - assert.equal(document.getElementById('locale-picker'), null); + assert.equal(document.getElementById('locale-picker') === null, true); }); diff --git a/src/ui/newsPanel.test.mjs b/src/ui/newsPanel.test.mjs index 1bf4ee7b7..d0e50346c 100644 --- a/src/ui/newsPanel.test.mjs +++ b/src/ui/newsPanel.test.mjs @@ -156,7 +156,7 @@ test('the panel shows the last 24 h as event cards and opens once', () => { assert.equal(img.getAttribute('referrerpolicy'), 'no-referrer'); assert.equal(first.querySelector('.news-card__credit').textContent, 'A'); // A lone report has no picture. - assert.equal(second.querySelector('.news-card__thumb'), null); + assert.equal(second.querySelector('.news-card__thumb') === null, true); // A later update does not reopen a panel the reader collapsed. panel.classList.add('collapsed'); news.update(view()); @@ -192,8 +192,8 @@ test('a card click flies there; open, it links the source and closes', () => { test('filters, the Local section and empty or failed reads', () => { const { news, panel, calls } = fixture(); news.update(view()); - assert.equal(panel.querySelector('[aria-label="Local"]'), null); - assert.equal(panel.querySelector('.news-toggle'), null); + assert.equal(panel.querySelector('[aria-label="Local"]') === null, true); + assert.equal(panel.querySelector('.news-toggle') === null, true); const severity = panel.querySelector('select[aria-label="Severity"]'); assert.equal(severity.value, '2'); severity.value = '3'; @@ -214,7 +214,7 @@ test('filters, the Local section and empty or failed reads', () => { panel.textContent, /No news matches these filters in the last 24 h/, ); - assert.equal(panel.querySelector('.news-readout__footer button'), null); + assert.equal(panel.querySelector('.news-readout__footer button') === null, true); // Local: no hotspot chips or severity floor, and its own empty states. const localView = (inView) => @@ -225,9 +225,9 @@ test('filters, the Local section and empty or failed reads', () => { news.update(localView({ active: false })); const section = panel.querySelector('[aria-label="Local"]'); assert.match(section.textContent, /Zoom in to see local news/); - assert.equal(panel.querySelector('[data-chip-id="ua-ru-war"]'), null); - assert.equal(panel.querySelector('select[aria-label="Severity"]'), null); - assert.equal(panel.querySelector('[aria-label="Latest news"]'), null); + assert.equal(panel.querySelector('[data-chip-id="ua-ru-war"]') === null, true); + assert.equal(panel.querySelector('select[aria-label="Severity"]') === null, true); + assert.equal(panel.querySelector('[aria-label="Latest news"]') === null, true); assert.equal(document.getElementById('news-panel-count').textContent, '0'); news.update( localView({ active: true, items: [feedCardView(report('near'), NOW)] }), @@ -320,7 +320,7 @@ test('a card credits the service its report came through', () => { credited.querySelector('.news-card__via').textContent, 'via GDELT Project', ); - assert.equal(own.querySelector('.news-card__via'), null); + assert.equal(own.querySelector('.news-card__via') === null, true); news.destroy(); }); @@ -370,7 +370,7 @@ test('scrolling to the end asks once per page, again after a filter change or a test('the window choice offers shorter windows; the longest is the server window', () => { const { news, panel, calls } = fixture(); news.update(view({ windowHours: 12, windowChoices: [12, 6, 3, 1] })); - assert.equal(panel.querySelector('.news-readout__window'), null); + assert.equal(panel.querySelector('.news-readout__window') === null, true); const choice = panel.querySelector('select[aria-label="Time window"]'); assert.deepEqual( [...choice.options].map((option) => option.textContent), @@ -410,7 +410,7 @@ test('a clip waits behind its picture and plays, on request, in the open card', news.update(view({ items: [feedCardView(clipped, NOW)] })); const card = panel.querySelector('.news-card'); assert.equal(card.querySelector('.news-card__play').textContent, '▶'); - assert.equal(card.querySelector('video'), null); + assert.equal(card.querySelector('video') === null, true); news.update( view({ items: [feedCardView(clipped, NOW)], @@ -436,6 +436,6 @@ test('a clip waits behind its picture and plays, on request, in the open card', selected: feedCardView(clipped, NOW), }), ); - assert.equal(panel.querySelector('.news-card.is-open video'), null); + assert.equal(panel.querySelector('.news-card.is-open video') === null, true); news.destroy(); }); diff --git a/src/ui/panelChromeButtons.test.mjs b/src/ui/panelChromeButtons.test.mjs index ae3a212c0..75678f8e6 100644 --- a/src/ui/panelChromeButtons.test.mjs +++ b/src/ui/panelChromeButtons.test.mjs @@ -166,8 +166,8 @@ test('UiPanel without collapsible has no button, and glow is optional', (t) => { }); const panel = document.getElementById('plain-panel'); assert.equal(panel.tagName, 'SECTION'); - assert.equal(panel.querySelector('.panel-glow'), null); - assert.equal(panel.querySelector('.panel-collapse-btn'), null); + assert.equal(panel.querySelector('.panel-glow') === null, true); + assert.equal(panel.querySelector('.panel-collapse-btn') === null, true); }); test('RailPanel follows the store, forwards clicks, and never restores a class its owner removed', async (t) => { diff --git a/src/ui/providerSettings.test.mjs b/src/ui/providerSettings.test.mjs index 117a63390..b0c8641ec 100644 --- a/src/ui/providerSettings.test.mjs +++ b/src/ui/providerSettings.test.mjs @@ -135,8 +135,8 @@ test('the chip counts what is missing and the rows render from the status', asyn ); assert.equal(field.type, 'password'); assert.equal(field.placeholder, 'paste GOOGLE_MAPS_API_KEY'); - assert.equal(google.querySelector('.key-setup-remove'), null); - assert.equal(google.querySelector('.key-setup-cloud-auth'), null); + assert.equal(google.querySelector('.key-setup-remove') === null, true); + assert.equal(google.querySelector('.key-setup-cloud-auth') === null, true); const openai = $('[data-key-id="openai"]'); assert.equal(openai.dataset.managed, 'file'); @@ -159,7 +159,7 @@ test('the chip counts what is missing and the rows render from the status', asyn firms.querySelector('.key-setup-external').textContent, 'configured externally', ); - assert.equal(firms.querySelector('.key-setup-fields'), null); + assert.equal(firms.querySelector('.key-setup-fields') === null, true); }); test('the chip opens the dialog onto its first field; closing fades it and restores the status line', async (t) => { @@ -337,12 +337,12 @@ test('Cyber moves the chip into the top toolbar and other themes return it to th await wait(); await nextTick(); assert.equal(toolbar.lastElementChild?.id, 'key-setup-chip'); - assert.equal(corner.querySelector('#key-setup-chip'), null); + assert.equal(corner.querySelector('#key-setup-chip') === null, true); document.documentElement.dataset.uiTheme = 'tactical'; await wait(); await nextTick(); assert.equal(corner.firstElementChild?.id, 'key-setup-chip'); - assert.equal(toolbar.querySelector('#key-setup-chip'), null); + assert.equal(toolbar.querySelector('#key-setup-chip') === null, true); delete document.documentElement.dataset.uiTheme; }); diff --git a/src/ui/quebecCctvOverlay.test.mjs b/src/ui/quebecCctvOverlay.test.mjs index 3dd715fe4..2ce76a9de 100644 --- a/src/ui/quebecCctvOverlay.test.mjs +++ b/src/ui/quebecCctvOverlay.test.mjs @@ -188,5 +188,5 @@ test('the refresh timer reloads with a cache-buster, skips hidden tabs, and stop ui.overlay.dispose(); assert.equal(ui.intervals.size, 0); assert.equal(ui.renderListeners.size, 0); - assert.equal(ui.window.document.querySelector('.qci-panel'), null); + assert.equal(ui.window.document.querySelector('.qci-panel') === null, true); }); diff --git a/src/ui/renderFailureView.test.mjs b/src/ui/renderFailureView.test.mjs index 89213bffa..1c54ac56d 100644 --- a/src/ui/renderFailureView.test.mjs +++ b/src/ui/renderFailureView.test.mjs @@ -72,7 +72,7 @@ test('a lost context offers Reload, and a panel that cannot reload offers nothin document.getElementById('render-failure-message').textContent, /3D graphics/, ); - assert.equal(notice().querySelector('.render-failure-detail'), null); + assert.equal(notice().querySelector('.render-failure-detail') === null, true); assert.deepEqual(buttons(), ['Reload', 'Reset view']); store.publishRenderFailure({ reason: 'context-lost', @@ -82,7 +82,7 @@ test('a lost context offers Reload, and a panel that cannot reload offers nothin }); await nextTick(); assert.deepEqual(buttons(), []); - assert.equal(notice().querySelector('.render-failure-hint'), null); + assert.equal(notice().querySelector('.render-failure-hint') === null, true); }); const failure = () => ({ diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 0a8a335f5..57d7da55a 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -96,13 +96,20 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { span: spanOf(state), }); const time = shallowRef(read()); - // The speed the world clock keeps, where it is one of those offered. - const speed = shallowRef( - Object.keys(WORLD_TIME_SPEEDS).find( - (key) => WORLD_TIME_SPEEDS[key] === worldClock.getRate(), - ) ?? 'real', - ); - const rate = () => WORLD_TIME_SPEEDS[speed.value]; + // The speed shown follows the world clock's rate, whoever set it: a rate + // not among those offered shows as the nearest that is (by ratio), while + // play still runs at the clock's own rate. + const speedFor = (clockRate) => { + let best = 'real'; + for (const key of Object.keys(WORLD_TIME_SPEEDS)) + if ( + Math.abs(Math.log(WORLD_TIME_SPEEDS[key] / clockRate)) < + Math.abs(Math.log(WORLD_TIME_SPEEDS[best] / clockRate)) + ) + best = key; + return best; + }; + const speed = shallowRef(speedFor(worldClock.getRate())); let ticker = null; const syncTicker = () => { // Playing, the clock tells of the time itself. @@ -117,6 +124,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { } }; const unsubscribe = worldClock.subscribe((state) => { + if (speed.value !== speedFor(state.rate)) + speed.value = speedFor(state.rate); if (stopAtEnd(state)) return; time.value = read(state); syncTicker(); @@ -131,7 +140,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { return { time, /** - * Play runs the track at the speed chosen, from the start when it is at + * Play runs the track at the world clock's rate (the speed chosen, which + * sets it), 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: weather follows the time frame by frame like every * other layer the track covers. @@ -142,7 +152,8 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { const covered = reach(); if (span && covered && (live || timeMs >= covered.end)) worldClock.seek(span.start); - worldClock.play({ rate: rate(), continuous: true }); + // At the clock's rate: the speed chosen, or a host's own. + worldClock.play({ continuous: true }); }, speed, /** @@ -154,10 +165,12 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { setSpeed(next) { if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return; speed.value = next; + // The clock's rate first: the speed shown follows it from here on. + worldClock.setRate(WORLD_TIME_SPEEDS[next]); const { playing, stepping } = worldClock.getState(); - if (!playing || !stepping) return worldClock.setRate(rate()); + if (!playing || !stepping) return; worldClock.pause(); - worldClock.play({ rate: rate(), continuous: true }); + worldClock.play({ continuous: true }); }, goLive: () => worldClock.goLive(), /** diff --git a/src/ui/views/mountApplicationViews.test.mjs b/src/ui/views/mountApplicationViews.test.mjs index a93e4ba14..ba472f9c0 100644 --- a/src/ui/views/mountApplicationViews.test.mjs +++ b/src/ui/views/mountApplicationViews.test.mjs @@ -25,7 +25,7 @@ test('omitted views lose their hosts and the rest render', () => { page(['welcome', 'provider-settings']); mountApplicationViews(document, { omit: ['provider-settings'] }); assert.deepEqual(hostNames(), ['welcome']); - assert.equal(document.getElementById('key-setup-chip'), null); + assert.equal(document.getElementById('key-setup-chip') === null, true); assert.ok( document.querySelector('[data-gev-view="welcome"]').children.length, ); @@ -37,7 +37,7 @@ test('a view an earlier call rendered is unmounted when a later call omits it', assert.ok(document.getElementById('key-setup-chip')); mountApplicationViews(document, { omit: ['provider-settings'] }); assert.deepEqual(hostNames(), ['welcome']); - assert.equal(document.getElementById('key-setup-chip'), null); + assert.equal(document.getElementById('key-setup-chip') === null, true); }); test('unknown view names are refused, in the page or in the omit list', () => { diff --git a/src/ui/views/panels/DrawMarkList.test.mjs b/src/ui/views/panels/DrawMarkList.test.mjs index 9eaf952db..b30f643b3 100644 --- a/src/ui/views/panels/DrawMarkList.test.mjs +++ b/src/ui/views/panels/DrawMarkList.test.mjs @@ -77,7 +77,7 @@ test('an application\'s row actions sit before fly to, labelled, and act on thei const note = row('a1').querySelector('.draw-mark-note'); assert.equal(note.getAttribute('role'), 'status'); assert.equal(note.textContent, ''); - assert.equal(row('a2').querySelector('.draw-mark-note'), null, 'a row without actions has no note'); + assert.equal(row('a2').querySelector('.draw-mark-note') === null, true, 'a row without actions has no note'); const line = { ...LINE, path: [[0, 0], [1, 0]] }; store.publishDraw({ active: true, shape: 'area', color: 'primary', hint: '', marks: presentDrawMarks([line], drawMarkActions([tag]), new Map([['a1', { busy: true, text: '' }]])) }); await nextTick(); diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 4588a71d3..f496d6d86 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -19,6 +19,8 @@ function mount(t) { // The store outlives the view; dispose it with its clock subscription. useWorldTimeStore(pinia).$dispose(); worldClock.goLive(); + // The clock keeps its rate; the next test starts at real time. + worldClock.setRate(1); }); return () => document.getElementById('world-timeline'); } @@ -79,16 +81,16 @@ test('the timeline hides while no layer with time to show is on', async (t) => { const bar = mount(t); const at = Date.UTC(2026, 9, 30, 6, 15, 42); await nextTick(); - assert.equal(bar(), null, 'live, nothing to play'); + assert.equal(bar() === null, true, 'live, nothing to play'); worldClock.seek(at); await nextTick(); - assert.equal(bar(), null, 'away from live, but nothing to play'); + assert.equal(bar() === null, true, 'away from live, but nothing to play'); const remove = supportingLayer(t, at); await nextTick(); assert.ok(bar()); remove(); await nextTick(); - assert.equal(bar(), null, 'the layer went off'); + assert.equal(bar() === null, true, 'the layer went off'); }); test('the track runs over the time the layers show, to now, and scrubs', async (t) => { @@ -465,7 +467,7 @@ test('play held for a layer to load shows a spinner once the hold lasts', async // A clock tick (a quarter second) tells of the hold; the spinner waits on. await new Promise((resolve) => setTimeout(resolve, 250)); await nextTick(); - assert.equal(bubble().querySelector('.world-timeline-spinner'), null, 'not for a quick frame'); + assert.equal(bubble().querySelector('.world-timeline-spinner') === null, true, 'not for a quick frame'); // The clock tells of the hold on its next tick, and the bar shows it after. const until = async (shown) => { for (let waited = 0; waited < 3_000; waited += 50) { @@ -615,7 +617,7 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId })); const tip = () => bar().querySelector('.world-timeline-mark-label'); assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'ticks stay ticks'); - assert.equal(tip(), null); + assert.equal(tip()?.textContent ?? null, null); // Hovering within 6 px of a tick shows its label, in place of the track's hint. press('pointermove', 205); @@ -628,15 +630,15 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async // Further off, or on a tick with no label, nothing shows. press('pointermove', 690); await nextTick(); - assert.equal(tip(), null); + assert.equal(tip()?.textContent ?? null, null); assert.equal(track.hasAttribute('title'), true); press('pointermove', 2); await nextTick(); - assert.equal(tip(), null, 'an unlabelled mark has no label'); + assert.equal(tip()?.textContent ?? null, null, 'an unlabelled mark has no label'); press('pointermove', 205); track.dispatchEvent(new PointerEvent('pointerleave', { clientX: 1_100, pointerId: 1 })); await nextTick(); - assert.equal(tip(), null, 'leaving the track hides it'); + assert.equal(tip()?.textContent ?? null, null, 'leaving the track hides it'); // A tap by a tick goes to its mark exactly; elsewhere, to the time there. press('pointerdown', 704); @@ -667,7 +669,7 @@ test('a tick shows its label on hover, and a tap by it goes to its mark', async assert.equal(tip()?.textContent, 'GAUNTLET PAYLOAD'); input.dispatchEvent(new Event('blur')); await nextTick(); - assert.equal(tip(), null); + assert.equal(tip()?.textContent ?? null, null); }); test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan clears it', async (t) => { @@ -717,7 +719,7 @@ test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true })); await nextTick(); assert.equal(track.classList.contains('is-zoomed'), true); - assert.equal(tip(), null, 'a zoom clears the label'); + assert.equal(tip()?.textContent ?? null, null, 'a zoom clears the label'); // So does a pan, by wheel or by drag. pointer('pointermove', firstAt()); await nextTick(); @@ -726,7 +728,7 @@ test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan new WheelEvent('wheel', { deltaY: 40, shiftKey: true, cancelable: true }), ); await nextTick(); - assert.equal(tip(), null, 'a pan clears the label'); + assert.equal(tip()?.textContent ?? null, null, 'a pan clears the label'); // A tap between them goes to the nearer mark. track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); @@ -737,3 +739,38 @@ test('the pointer shows the nearer of two ticks within 6 px, and a zoom or a pan await nextTick(); assert.equal(worldClock.now(), second, 'a tap goes to the nearer mark'); }); + +test('the speed shown follows the world clock, and Play runs at its rate', async (t) => { + const bar = mount(t); + const now = Date.now(); + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start: now - 10 * 86_400_000, end: now - 86_400_000 }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + const select = () => bar().querySelector('select.world-timeline-speed'); + assert.equal(select().value, 'real'); + // A host sets the rate: the selector shows it. + worldClock.setRate(86_400); + await nextTick(); + assert.equal(select().value, 'day'); + // A rate not offered shows as the nearest offered speed, and Play keeps it. + worldClock.setRate(5_000); + await nextTick(); + assert.equal(select().value, 'hour'); + bar().querySelector('.world-timeline-play').click(); + assert.equal(worldClock.isPlaying(), true); + assert.equal(worldClock.getRate(), 5_000, 'Play runs at the clock\'s own rate'); + worldClock.pause(); + // Chosen on the selector while paused, the clock takes it at once. + select().value = 'week'; + select().dispatchEvent(new Event('change')); + await nextTick(); + assert.equal(worldClock.getRate(), 604_800); + assert.equal(select().value, 'week'); +}); diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs index f685c75e0..fb9123c7c 100644 --- a/src/ui/wildlifeSightingCard.test.mjs +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -309,7 +309,7 @@ test('dot, icon and ambient photo clicks open one contained medium-photo card at true, 'the loaded medium photo is painted inside the card', ); - assert.equal(card.querySelector('[role=status]'), null); + assert.equal(card.querySelector('[role=status]') === null, true); const style = readFileSync( new URL('./views/panels/WildlifeSightingCard.vue', import.meta.url), 'utf8', @@ -343,11 +343,11 @@ test('missing, restricted, obscured and Acartia photos all use the same compact const f = fixture(context, { ...row, ...extra }); await f.click(); const card = f.cardElement(); - assert.equal(card.querySelector('img'), null); - assert.equal(card.querySelector('[role=status]'), null); + assert.equal(card.querySelector('img') === null, true); + assert.equal(card.querySelector('[role=status]') === null, true); assert.equal(card.querySelector('h2').textContent, row.commonName); assert.equal(card.querySelector('i').textContent, row.scientificName); - assert.equal(document.querySelector('dialog'), null); + assert.equal(document.querySelector('dialog') === null, true); if (extra.obscured) { assert.doesNotMatch(card.textContent, /ago|Photographer/); assert.ok( @@ -676,7 +676,7 @@ test('date-only and unsafe-link rows remain readable, and no sighting rail or li link: 'javascript:alert(1)', }); await f.click(); - assert.equal(f.cardElement().querySelector('a'), null); + assert.equal(f.cardElement().querySelector('a') === null, true); assert.equal( f.cardElement().querySelector('time').getAttribute('datetime'), row.observedOn,