diff --git a/changelog.d/1745-timeline-one-control-and-marks.md b/changelog.d/1745-timeline-one-control-and-marks.md new file mode 100644 index 000000000..2585b4760 --- /dev/null +++ b/changelog.d/1745-timeline-one-control-and-marks.md @@ -0,0 +1,51 @@ +- 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, 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 + 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. 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, #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 + 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 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 + clock as a driver; move time with the world clock (`gods-eye-view/time`, + also `weatherClock.world`) instead (samehkhamis, #1745). diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 003b15248..33036f31a 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -187,7 +187,28 @@ 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 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 (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 +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/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..2ba3165f0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1781,7 +1781,14 @@ 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. 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 @@ -8532,9 +8539,25 @@ 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 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 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). 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 +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. 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 +8567,35 @@ 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 +(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: `play` runs at the speed chosen on +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 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. -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..95e87d082 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", @@ -1766,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"] }, @@ -3168,7 +3165,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 +3720,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/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/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/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/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/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/i18n/locales/ar.json b/src/i18n/locales/ar.json index 5fe57db79..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": "الغيوم من الأقمار الصناعية", @@ -2542,12 +2541,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 +2843,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..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)", @@ -2402,12 +2401,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 +2651,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..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", @@ -1667,12 +1666,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 +1831,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..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", @@ -2150,12 +2149,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 +2441,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..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": "עננים מלוויין", @@ -1996,11 +1995,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 +2778,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..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", @@ -2150,12 +2149,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 +2441,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..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": "衛星雲画像", @@ -2076,12 +2075,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 +2341,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..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", @@ -2150,12 +2149,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 +2441,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..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": "Спутниковая облачность", @@ -1697,12 +1696,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 +1862,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..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", @@ -2324,12 +2323,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 +2573,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..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": "Супутникова хмарність", @@ -2186,12 +2185,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 +2490,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..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": "卫星云图", @@ -2046,12 +2045,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 +2305,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..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": "衛星雲圖", @@ -2036,12 +2035,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 +2295,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/earthquakes/index.js b/src/layers/earthquakes/index.js index 0d522fcb9..0999681b7 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,21 @@ 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, place, usgsId }). + 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 covered = coverage(); + const key = covered ? JSON.stringify(covered.marks) : null; + if (key === _marksKey) return; + _marksKey = key; + worldClock.coverageChanged(); + }; const layer = { id: 'earthquakes', @@ -41,6 +58,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 +76,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 +86,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 +100,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { return false; const nextEntities = []; + const nextEvents = []; let count = 0; const overlayEntries = []; @@ -91,6 +115,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { time, } of rows) { count++; + nextEvents.push({ mag, time, place, usgsId }); const baseRadius = Math.pow(2, mag) * 1000; const color = depthColor(depthKm || 0); const isSignificant = mag >= 5.0; @@ -137,6 +162,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 +203,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..66471b59e 100644 --- a/src/layers/earthquakes/model.js +++ b/src/layers/earthquakes/model.js @@ -3,6 +3,64 @@ 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 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)}`; + +/** + * 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: Array<{time: number, label: string, id?: string, kind: string}>} | null} + */ +export function earthquakeCoverage(events) { + 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; +} + /** * Color by depth: * - Shallow (<70km): Red @@ -37,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 9951935de..c17514184 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, earthquakeMarkLabel } from './model.js'; +import { worldClock } from '../../time/worldClock.js'; function harness(source) { const sources = []; const events = []; @@ -88,3 +90,71 @@ 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', 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; + 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); + // 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); + 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(); +}); + +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.js b/src/layers/weather/clock.js index 8e539473e..547709132 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -59,34 +59,36 @@ 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. + * + * `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(); 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 +149,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 +163,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 +188,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; } @@ -254,11 +204,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 = () => { @@ -272,9 +224,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; }, }); @@ -294,81 +249,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 +271,18 @@ 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(); + // 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(); @@ -397,9 +291,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 +313,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 +326,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 +349,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(), - }); + if (shared) 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..ba2544073 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,74 +349,25 @@ 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) => { +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); @@ -511,11 +377,17 @@ 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: [], + markDetails: [], }); 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: [], + markDetails: [], + }); clock.destroy(); assert.equal(world.coverage(), null); assert.equal(world.getState().supported, false); @@ -528,10 +400,12 @@ 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: [], + markDetails: [], + }); }); 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 55593a437..ec6fea7f0 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'; @@ -53,14 +55,68 @@ const utc = (value) => ? `${formatUtcDayMonth(value)} ${value.slice(11, 16)} UTC` : t('Unavailable'); +/** + * 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 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. + */ +function moveWorldTime(world, frames, { play, latest, step }, canPlay) { + if (!world) return; + if (play === true) { + const state = world.getState(); + if (state.playing) world.pause(); + 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(); + if (step === -1 || step === 1) { + const at = world.getState().timeMs; + const times = frames + .map((time) => Date.parse(time)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + const to = + step < 0 + ? times.findLast((frame) => frame < at) + : times.find((frame) => frame > at); + if (to === undefined) return; + world.pause(); + world.seek(to); + } +} + +// 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. 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 + * 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({ feed, - clock, + clock: sharedClock, id = 'weather-radar', cesium = Cesium, createRendering = createWeatherRendering, @@ -70,6 +126,10 @@ export function createWeatherLayer({ } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Weather requires a snapshot source'); + // 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'; @@ -96,12 +156,10 @@ export function createWeatherLayer({ manifest = null, request = null, listener = null; + let destroyed = false; let enabled = false, - loading = false, - playing = false, - followLatest = true; + loading = false; let generation = 0, - timer = null, error = null, removeCamera = null; let motion = null; @@ -141,32 +199,31 @@ export function createWeatherLayer({ return spaced; }; const maxGap = () => spacedTimes().gap; - const isLatest = () => - clock ? clock.getState().mode === 'latest' : followLatest; + 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 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(); }; @@ -181,46 +238,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); @@ -234,7 +260,7 @@ export function createWeatherLayer({ return true; } function registerClock() { - if (!clock || unregisterClock || !rendering || !enabled) return; + if (unregisterClock || !rendering || !enabled || !clock) return; unregisterClock = clock.register({ id, get maxGapMs() { @@ -247,15 +273,11 @@ export function createWeatherLayer({ }); } async function warmNext(time) { - if ( - !enabled || - suspended() || - !(clock ? clock.getState().advancing : playing) - ) - 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) % 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 { @@ -290,13 +312,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); @@ -304,8 +323,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(); } } } @@ -352,7 +370,10 @@ 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) { removeCamera = viewer.camera.moveEnd?.addEventListener(() => { checkHost(); @@ -373,26 +394,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(); @@ -408,36 +423,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 && @@ -447,7 +441,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); @@ -455,7 +448,6 @@ export function createWeatherLayer({ request = null; loading = Boolean(rendering?.getDiagnostics().loading); notify(); - schedule(); } } }, @@ -490,19 +482,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 ( @@ -528,38 +517,19 @@ 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); - } - 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), + // Time is the world's: these move the world clock as its timeline + // 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), ); - void show(manifest.times[next]); - } notify(); }, getParams() { @@ -570,13 +540,13 @@ 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); 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}', { @@ -702,9 +672,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, @@ -894,27 +862,26 @@ export function createWeatherLayer({ }; }, getDiagnostics() { - const shared = clock?.getState(); + const shared = clockState(); return { ...rendering?.getDiagnostics(), - playing: shared ? shared.playing : playing && !hostHidden, + playing: shared.advancing, followLatest: isLatest(), - ...(shared - ? { - clock: { - mode: shared.mode, - target: shared.target, - playing: shared.playing, - }, - } - : {}), + clock: { + mode: shared.mode, + target: shared.target, + playing: shared.advancing, + }, historyFrames: manifest?.times?.length || 0, - timerActive: timer !== null, }; }, destroy() { + destroyed = true; layer.disable(); 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 e1ba553e4..309b51165 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'; @@ -35,6 +36,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 { @@ -472,12 +483,22 @@ 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(); + worldClock.setRate(1); +}); function layerHarness({ clock, reducedMotion = false, feed, id, eventTarget = target(), + enable = true, } = {}) { const stages = []; const motion = target({ matches: reducedMotion }); @@ -551,7 +572,8 @@ function layerHarness({ createRendering: () => rendering, }); layer.init(viewer); - layer.enable(); + if (enable) layer.enable(); + harnessed.add(layer); return { layer, rendering, @@ -565,72 +587,189 @@ 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); + // 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: [], + markDetails: [], + }); 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('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('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(); + 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(); 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, ); - 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'); + // Play, which the row does not offer, does nothing while it is suspended. 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(), 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 }); + await flush(); + 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 frame after the newest', + ); + worldClock.seek(Date.parse(times[1])); + await flush(); + h.layer.setParams({ play: true }); + 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) => { @@ -659,7 +798,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(); }); @@ -719,7 +857,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(); }); @@ -827,21 +964,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]); @@ -871,7 +1009,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); @@ -1246,8 +1385,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: { @@ -1263,28 +1401,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); }); @@ -1421,25 +1554,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); @@ -1638,10 +1767,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); @@ -1765,7 +1896,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 +1929,14 @@ test('one row steps every registered observation; missing frames hide and latest .chips.filter(({ id }) => ['previous', 'play', 'next', 'latest'].includes(id), ); + // 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 +1988,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 +2027,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 +2096,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 +2155,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 +2183,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 +2497,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 +2517,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/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/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 ad83f6a41..9beff2d93 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -14,11 +14,85 @@ * 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. */ +/** + * 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']; +/** + * 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. + * @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; @@ -191,15 +265,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(); @@ -270,7 +346,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'); @@ -371,7 +450,16 @@ 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`; 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. + * @param {{id: string, coverage: () => ({start: number, end: number, marks?: Array} | null)}} source */ register(source) { // Each registration is its own, even under a shared id. @@ -386,20 +474,48 @@ 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); + // 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) + .map(shownMark); + 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..8c0cbe5b8 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 = { @@ -253,9 +257,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', () => { @@ -306,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 = []; @@ -317,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', () => { @@ -408,3 +469,68 @@ 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)); +}); + +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/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/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 9dcb7d4c8..57d7da55a 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) => @@ -93,8 +96,20 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { span: spanOf(state), }); const time = shallowRef(read()); - const speed = shallowRef('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. @@ -109,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(); @@ -123,10 +140,11 @@ 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, 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; @@ -134,22 +152,25 @@ 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, /** - * 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 + * 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; + // 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) return; - if (!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/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/styles/world-timeline.css b/src/ui/styles/world-timeline.css index f644f8543..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; @@ -194,6 +194,31 @@ 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, 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; + 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/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 7785e86c1..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) { @@ -581,3 +583,194 @@ 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()?.textContent ?? null, 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()?.textContent ?? null, null); + assert.equal(track.hasAttribute('title'), true); + press('pointermove', 2); + await nextTick(); + 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()?.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); + 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()?.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) => { + 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()?.textContent ?? null, 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()?.textContent ?? null, 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'); +}); + +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/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 866380638..054e4370d 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) @@ -109,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(); @@ -138,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; }; @@ -212,6 +218,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 +248,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 +277,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 +293,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,10 +366,11 @@ 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), +// 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']; const onKey = (event) => { if (ZOOM_KEYS.includes(event.key)) { @@ -331,17 +404,18 @@ const onKey = (event) => { >
{ @input="scrub" aria-keyshortcuts="+ - 0" @keydown="onKey" + @focus="focused = true" + @blur="focused = false" />
{{ t('Live') }}
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,30 @@ 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. 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' + ? 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 +218,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..c12292214 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,55 @@ 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); - f.state({ timeline: [ticks[0]] }); - assert.equal(timelineHost(f).hidden, false); - assert.equal(f.find((n) => n.tagName === 'INPUT').disabled, true); - 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, false); + assert.equal(frameTime(f).textContent, '01:05 UTC · 12 min ago'); + 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: [], 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( + 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 +191,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 +422,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 +684,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/, 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,