Merge pull request #1745 from bilawalsidhu/timeline/one-control-and-marks

One time control; the timeline marks launches and strong earthquakes, with labels
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-14 21:24:45 -08:00
commit e4708a815d
63 files changed
+1609 -1389

No files matched your search

@@ -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).
+22 -1
View File
@@ -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,
+7 -8
View File
@@ -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
+53 -11
View File
@@ -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.
+3 -3
View File
@@ -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
-2
View File
@@ -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",
+2 -7
View File
@@ -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",
@@ -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) ───────────────────────────────────────
+19 -3
View File
@@ -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);
+2 -2
View File
@@ -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);
+9 -1
View File
@@ -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;
+2 -2
View File
@@ -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);
});
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
-6
View File
@@ -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}",
+31
View File
@@ -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;
+59 -1
View File
@@ -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',
+70
View File
@@ -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',
);
});
+21 -6
View File
@@ -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 };
},
});
+45 -155
View File
@@ -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;
}
+74 -200
View File
@@ -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) => {
+125 -158
View File
@@ -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);
+271 -103
View File
@@ -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']) {
+7 -3
View File
@@ -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();
});
@@ -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');
+1
View File
@@ -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, []);
+128 -12
View File
@@ -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<number | WorldClockMarkInput>} | 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,
+130 -4
View File
@@ -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);
}
});
+3 -3
View File
@@ -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 });
+3 -3
View File
@@ -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/,
+8 -8
View File
@@ -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();
+1 -1
View File
@@ -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']);
+2 -2
View File
@@ -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"]'),
+2 -2
View File
@@ -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);
});
+11 -11
View File
@@ -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();
});
+2 -2
View File
@@ -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) => {
+5 -5
View File
@@ -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;
});
+1 -1
View File
@@ -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);
});
-178
View File
@@ -1,178 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
import { formatUtcDayMonth, t } from '../../i18n/index.js';
import { utcClock } from './utcClock.js';
import UiSlider from '../kit/UiSlider.js';
// Stable native transport with local preview and a coalesced 150 ms drag
// commit. While the handle is dragged the slider keeps the ticks it was
// dragged over; the owner's updates apply once the drag ends.
const props = defineProps({
state: { type: Object, required: true },
sliderClassName: { type: String, default: '' },
heading: { type: Boolean, default: true },
onCommit: { type: Function, default: () => {} },
onPreview: { type: Function, default: () => {} },
onStep: { type: Function, default: () => {} },
onLatest: { type: Function, default: () => {} },
onPlay: { type: Function, default: () => {} },
});
const utc = (tick) =>
Number.isFinite(Date.parse(tick))
? `${formatUtcDayMonth(tick)} ${utcClock(tick)}`
: '';
// What the slider shows: the owner's ticks, index and readout, frozen while
// dragging.
const shown = shallowRef({ ticks: [], index: 0, readout: '' });
let dragging = false;
let timer = null;
let pending = null;
let live = true;
const cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
const sync = () => {
if (dragging) return;
const ticks = [...props.state.ticks];
shown.value = {
ticks,
index: Math.max(0, Math.min(props.state.index, ticks.length - 1)),
readout: props.state.readout || '',
};
};
watch(
() => props.state,
(state) => {
if (state.disabled) {
cancel();
pending = null;
dragging = false;
}
sync();
},
{ immediate: true, flush: 'sync' },
);
onBeforeUnmount(() => {
live = false;
cancel();
});
const disabled = computed(() => Boolean(props.state.disabled));
const latestActive = computed(() => props.state.mode === 'latest');
const playing = computed(() => Boolean(props.state.playing));
function commit() {
cancel();
const value = pending;
pending = null;
if (live && value && !props.state.disabled)
props.onCommit(value.tick, value.index);
}
function preview(event) {
if (props.state.disabled) return;
dragging = true;
const index = Number(event.target.value);
const tick = shown.value.ticks[index];
if (!tick) return;
pending = { tick, index };
// Shown before the owner hears of it: an update from inside onPreview
// must not move the handle back.
shown.value = { ...shown.value, index };
const readout = props.onPreview(tick, index);
// The owner may have ended the drag (disabled) from inside onPreview.
if (!dragging) return;
if (typeof readout === 'string') shown.value = { ...shown.value, readout };
if (timer === null) timer = setTimeout(commit, 150);
}
// The handle shows the shown index again. A drag that ends where it began
// (an input and a cancel, or a refused commit, before the next render)
// leaves the slider's model unchanged, so it would not render.
const place = (input) => {
const index = String(shown.value.index);
if (input && input.value !== index) input.value = index;
};
function change(event) {
if (props.state.disabled) return;
const index = Number(event.target.value);
pending = { tick: shown.value.ticks[index], index };
dragging = false;
commit();
sync();
place(event.target);
}
function reset(event) {
cancel();
pending = null;
dragging = false;
sync();
place(event?.target);
}
function blur(event) {
if (dragging) change(event);
}
const act = (fn) => {
reset();
if (live && !props.state.disabled) fn();
};
</script>
<template>
<section class="rail-timeline"
><div v-if="heading" class="panel-title">{{ t('Observed history') }}</div
><div class="rail-timeline-track"
><button
type="button"
class="data-toggle-chip"
:title="t('Earlier observation')"
:disabled="disabled || state.index <= 0"
@click="act(() => onStep(-1))"
>‹</button
><UiSlider
:class="sliderClassName || undefined"
min="0"
:max="String(Math.max(0, shown.ticks.length - 1))"
step="1"
:model-value="String(shown.index)"
:disabled="disabled"
:aria-label="t('Observed history')"
:aria-valuetext="utc(shown.ticks[shown.index])"
@input="preview"
@change="change"
@pointercancel="reset"
@blur="blur"
/><button
type="button"
class="data-toggle-chip"
:title="t('Later observation')"
:disabled="disabled || state.index >= state.ticks.length - 1"
@click="act(() => onStep(1))"
>›</button
><div class="rail-timeline-endpoints"
><span>{{ utcClock(shown.ticks[0]) }}</span
><span>{{ utcClock(shown.ticks.at(-1)) }}</span></div
></div
><div class="rail-timeline-controls"
><button
type="button"
:class="`data-toggle-chip${latestActive ? ' active' : ''}`"
:title="t('Newest frame per product')"
:disabled="disabled"
:aria-pressed="String(latestActive)"
@click="act(onLatest)"
>{{ t('Latest') }}</button
><button
type="button"
:class="`data-toggle-chip${playing ? ' active' : ''}`"
:title="t('Replay observed history')"
:disabled="disabled"
:aria-pressed="String(playing)"
@click="act(onPlay)"
>{{ playing ? t('Pause') : t('Play') }}</button
><span class="rail-timeline-readout">{{ shown.readout }}</span></div
></section
>
</template>
-56
View File
@@ -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);
},
};
}
-178
View File
@@ -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();
});
+2 -2
View File
@@ -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 = () => ({
+33 -12
View File
@@ -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(),
/**
+4 -46
View File
@@ -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);
+26 -1
View File
@@ -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;
+2 -2
View File
@@ -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', () => {
+1 -1
View File
@@ -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();
+197 -4
View File
@@ -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');
});
+91 -15
View File
@@ -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) => {
><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()"
><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton
><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)"
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom, drag to move along · double-click to see it all')" @wheel="onWheel" @dblclick="showAll" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp"
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="markTip?.hover ? undefined : t('Scroll or pinch to zoom, drag to move along · double-click to see it all')" @wheel="onWheel" @dblclick="showAll" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp" @pointerleave="hovered = null"
><time class="world-timeline-time" :class="{ 'is-waiting': waiting }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off"
><span v-if="waiting" class="world-timeline-spinner" aria-hidden="true"></span>{{ bubble }}</time
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
><span v-if="markTip" class="world-timeline-mark-label" :style="{ '--at': markTip.share }" aria-hidden="true">{{ markTip.text }}</span
><span v-for="tick in ticks" :key="tick.time" class="world-timeline-tick" :style="{ left: `${tick.share * 100}%` }" aria-hidden="true"></span
><span class="world-timeline-fill" :style="{ width: `${place(shown)}%` }" aria-hidden="true"></span
><input
class="world-timeline-scrub"
:class="{ 'is-off-view': handleOff }"
type="range"
:aria-label="t('World time')"
:aria-valuetext="readout"
:aria-valuetext="valueText"
:min="view.start"
:max="view.end"
step="any"
@@ -349,6 +423,8 @@ const onKey = (event) => {
@input="scrub"
aria-keyshortcuts="+ - 0"
@keydown="onKey"
@focus="focused = true"
@blur="focused = false"
/></div
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
></div>
+5 -13
View File
@@ -1,15 +1,14 @@
<script setup>
import { t } from '../../i18n/index.js';
import RailCards from '../rail/RailCards.vue';
import RailTimeline from '../rail/RailTimeline.vue';
// Active weather: forecast and event cards, then the observed products with
// their shared history timeline. The owner computes every value shown.
// the time of the frame they show. The world timeline moves time; the owner
// computes every value shown.
defineProps({
view: { type: Object, required: true },
onParams: { type: Function, default: () => {} },
onOpen: { type: Function, default: () => {} },
transport: { type: Object, required: true },
});
</script>
@@ -31,16 +30,9 @@ defineProps({
:aria-label="t('Observed history')"
><h3 class="panel-title">{{ t('Observed history') }}</h3
><div class="weather-observed-scope">{{ view.scope }}</div
><div class="weather-timeline-block" :hidden="!view.observed"
><RailTimeline
:state="view.timeline"
slider-class-name="weather-timeline"
:heading="false"
:on-commit="transport.onCommit"
:on-preview="transport.onPreview"
:on-step="transport.onStep"
:on-latest="transport.onLatest"
:on-play="transport.onPlay" /></div
><div class="weather-frame-time" :hidden="!view.frameTime">{{
view.frameTime
}}</div
><div class="weather-cards"
><RailCards
:cards="view.observedCards"
+21 -37
View File
@@ -88,13 +88,6 @@ export function createWeatherPanel({
openPanels.set(page, id);
render();
},
transport: {
onCommit: (tick) => 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)),
});
+45 -38
View File
@@ -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/,
+5 -5
View File
@@ -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,