mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
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:
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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) ───────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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']) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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"]'),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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(),
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user