mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
The timeline marks launches and strong earthquakes, not every weather frame
The timeline drew a tick for every registered mark, and the weather clock registered every radar, satellite and lightning frame, which come every few minutes, so the track was a wall of dashes and the arrow keys crept from frame to frame. Weather products now register only the span of their frames (start and end, as before) with no marks. Holds, the gate and the next-frame wait while the world plays are unchanged. A weather row's step param goes to the previous or next weather frame (every shown product's frames, and the row's own while it is suspended) rather than to the timeline's marks. The Earthquakes layer registers with the world clock once, at init, and unregisters on destroy. While on, its quakes of magnitude 6 and up (EARTHQUAKE_MARK_MAGNITUDE) are marks, and its coverage is their own span, or none without any, so it never stretches the timeline beyond the feed. Off, it has none. It tells the clock when the set of marks changes. The map still draws every M2.5+ quake. Launches stay marks. With no mark ahead or behind, the arrow keys step along the track as shown.
This commit is contained in:
1 parent
326cab4cf2
commit
7cb854c923
11 files changed
+181
-41
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
import {
|
||||
EARTHQUAKE_OVERLAY_SOURCE_ID,
|
||||
EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
createEarthquakeOverlayEntry,
|
||||
selectEarthquakeOverlayCohort,
|
||||
mapAnalystRecord,
|
||||
earthquakeCoverage,
|
||||
} from './model.js';
|
||||
export * from './model.js';
|
||||
export { createUsgsEarthquakeSource } from './source.js';
|
||||
@@ -27,6 +29,19 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
let _lastUpdate = null;
|
||||
let _lastError = null;
|
||||
let _enabled = false;
|
||||
// The quakes last drawn, as the timeline reads them ({ mag, time }).
|
||||
let _events = [];
|
||||
let _uncover = null;
|
||||
// The marks last told to the clock, so a refresh with the same strong
|
||||
// quakes does not tell it again.
|
||||
let _marksKey = null;
|
||||
const coverage = () => (_enabled ? earthquakeCoverage(_events) : null);
|
||||
const coverageChanged = () => {
|
||||
const key = coverage()?.marks.join() ?? null;
|
||||
if (key === _marksKey) return;
|
||||
_marksKey = key;
|
||||
worldClock.coverageChanged();
|
||||
};
|
||||
|
||||
const layer = {
|
||||
id: 'earthquakes',
|
||||
@@ -41,6 +56,10 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
_dataSource = new Cesium.CustomDataSource('earthquakes');
|
||||
_dataSource.show = false;
|
||||
viewer.dataSources.add(_dataSource);
|
||||
// While on, the strong quakes are marks on the world clock's timeline.
|
||||
// Registered once; off, the layer has no time to show.
|
||||
_marksKey = null;
|
||||
_uncover = worldClock.register({ id: 'earthquakes', coverage });
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
@@ -55,6 +74,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
// layer has no per-frame animator to keep the render loop alive for.
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true);
|
||||
coverageChanged();
|
||||
},
|
||||
|
||||
disable(viewer) {
|
||||
@@ -64,6 +84,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false);
|
||||
coverageChanged();
|
||||
},
|
||||
|
||||
async update(viewer) {
|
||||
@@ -77,6 +98,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
return false;
|
||||
|
||||
const nextEntities = [];
|
||||
const nextEvents = [];
|
||||
let count = 0;
|
||||
const overlayEntries = [];
|
||||
|
||||
@@ -91,6 +113,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
time,
|
||||
} of rows) {
|
||||
count++;
|
||||
nextEvents.push({ mag, time });
|
||||
const baseRadius = Math.pow(2, mag) * 1000;
|
||||
const color = depthColor(depthKm || 0);
|
||||
const isSignificant = mag >= 5.0;
|
||||
@@ -137,6 +160,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
|
||||
_dataSource.entities.removeAll();
|
||||
for (const entity of nextEntities) _dataSource.entities.add(entity);
|
||||
_events = nextEvents;
|
||||
coverageChanged();
|
||||
if (_enabled) {
|
||||
overlayHost.setEntries(
|
||||
EARTHQUAKE_OVERLAY_SOURCE_ID,
|
||||
@@ -176,6 +201,10 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) {
|
||||
viewer.dataSources.remove(_dataSource, true);
|
||||
_dataSource = null;
|
||||
}
|
||||
_events = [];
|
||||
_uncover?.();
|
||||
_uncover = null;
|
||||
_marksKey = null;
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
|
||||
@@ -3,6 +3,34 @@ export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes';
|
||||
export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96;
|
||||
export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48;
|
||||
|
||||
/**
|
||||
* The smallest magnitude the timeline marks. Most quakes are small shakes;
|
||||
* M6 and up come about once every two days worldwide, so each is an event
|
||||
* worth stepping to. The map still draws every quake.
|
||||
*/
|
||||
export const EARTHQUAKE_MARK_MAGNITUDE = 6;
|
||||
|
||||
/**
|
||||
* The time a world clock's timeline can show for `events` (`{mag, time}`,
|
||||
* time in ms): the span of the quakes of EARTHQUAKE_MARK_MAGNITUDE and up,
|
||||
* each one a mark, or null with none, so the layer never stretches the
|
||||
* timeline past what its feed covers.
|
||||
* @returns {{start: number, end: number, marks: number[]} | null}
|
||||
*/
|
||||
export function earthquakeCoverage(events) {
|
||||
const marks = [
|
||||
...new Set(
|
||||
events
|
||||
.filter(
|
||||
({ mag, time }) =>
|
||||
mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time),
|
||||
)
|
||||
.map(({ time }) => time),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
return marks.length ? { start: marks[0], end: marks.at(-1), marks } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Color by depth:
|
||||
* - Shallow (<70km): Red
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createEarthquakesLayer } from './index.js';
|
||||
import { createUsgsEarthquakeSource } from './source.js';
|
||||
import { EARTHQUAKE_MARK_MAGNITUDE } from './model.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
function harness(source) {
|
||||
const sources = [];
|
||||
const events = [];
|
||||
@@ -88,3 +90,40 @@ test('USGS body completion honors cancellation even with an uncooperative transp
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
test('the timeline marks only strong quakes, while the layer is on', async () => {
|
||||
assert.equal(EARTHQUAKE_MARK_MAGNITUDE, 6);
|
||||
const rows = [
|
||||
{ ...row, stableId: 'small', mag: 4.5, time: 1_000 },
|
||||
{ ...row, stableId: 'strong', mag: 6, time: 5_000 },
|
||||
{ ...row, stableId: 'great', mag: 7.2, time: 3_000 },
|
||||
{ ...row, stableId: 'untimed', mag: 6.5, time: null },
|
||||
];
|
||||
let snapshot = rows;
|
||||
const h = harness({ getSnapshot: async () => snapshot });
|
||||
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
|
||||
let changes = 0;
|
||||
const unsubscribe = worldClock.subscribe(() => changes++);
|
||||
await h.layer.update(h.viewer);
|
||||
// Every quake is drawn; only M6 and up are marks, spanning their own time.
|
||||
assert.equal(h.sources[0].entities.values.length, 4);
|
||||
assert.deepEqual(worldClock.coverage(), {
|
||||
start: 3_000,
|
||||
end: 5_000,
|
||||
marks: [3_000, 5_000],
|
||||
});
|
||||
const told = changes;
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(changes, told, 'the same strong quakes are not told again');
|
||||
snapshot = rows.slice(0, 1);
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(worldClock.coverage(), null, 'small shakes alone cover no time');
|
||||
snapshot = rows;
|
||||
await h.layer.update(h.viewer);
|
||||
h.layer.disable(h.viewer);
|
||||
assert.equal(worldClock.coverage(), null, 'off, no time to show');
|
||||
h.layer.enable(h.viewer);
|
||||
assert.deepEqual(worldClock.coverage().marks, [3_000, 5_000]);
|
||||
h.layer.destroy();
|
||||
assert.equal(worldClock.coverage(), null);
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -198,11 +198,13 @@ export function createWeatherClock({
|
||||
throw new Error(`Duplicate weather product: ${product.id}`);
|
||||
const entry = { product, controller: null };
|
||||
registry.set(product.id, entry);
|
||||
// The product moves with the world clock, and its frames are time the
|
||||
// world clock's timeline can show while it is on, suspended or not, so
|
||||
// the way back to live stays on screen. Both calls notify the world's
|
||||
// listeners, which may destroy this clock: the release is in place
|
||||
// first, and anything acquired after a destroy is let go at once.
|
||||
// The product moves with the world clock, and the span of its frames is
|
||||
// time the world clock's timeline can show while it is on, suspended or
|
||||
// not, so the way back to live stays on screen. The frames are not
|
||||
// marks: they come every few minutes, and the timeline marks only
|
||||
// events. Both calls notify the world's listeners, which may destroy
|
||||
// this clock: the release is in place first, and anything acquired
|
||||
// after a destroy is let go at once.
|
||||
let unhold = null;
|
||||
let uncover = null;
|
||||
const release = () => {
|
||||
@@ -216,9 +218,12 @@ export function createWeatherClock({
|
||||
uncover = world.register({
|
||||
id: product.id,
|
||||
coverage: () => {
|
||||
const marks = timesFor(product).map((time) => Date.parse(time));
|
||||
return marks.length
|
||||
? { start: marks[0], end: marks.at(-1), marks }
|
||||
const times = timesFor(product);
|
||||
return times.length
|
||||
? {
|
||||
start: Date.parse(times[0]),
|
||||
end: Date.parse(times.at(-1)),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -367,7 +367,7 @@ test('weather clocks sharing a world: the last one destroyed leaves it live', ()
|
||||
assert.equal(world.isPlaying(), false);
|
||||
});
|
||||
|
||||
test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => {
|
||||
test('each product’s frames span world clock coverage, with no marks, and support the timeline while shown', (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
assert.equal(world.coverage(), null);
|
||||
assert.equal(world.getState().supported, false);
|
||||
@@ -377,11 +377,15 @@ test('each product’s frames are world clock coverage, and support the timeline
|
||||
assert.deepEqual(world.coverage(), {
|
||||
start: Date.parse(time(0)),
|
||||
end: Date.parse(time(10)),
|
||||
marks: [0, 5, 10].map((m) => Date.parse(time(m))),
|
||||
marks: [],
|
||||
});
|
||||
assert.equal(world.getState().supported, true);
|
||||
unregister();
|
||||
assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]);
|
||||
assert.deepEqual(world.coverage(), {
|
||||
start: Date.parse(time(5)),
|
||||
end: Date.parse(time(5)),
|
||||
marks: [],
|
||||
});
|
||||
clock.destroy();
|
||||
assert.equal(world.coverage(), null);
|
||||
assert.equal(world.getState().supported, false);
|
||||
@@ -394,10 +398,11 @@ test('a suspended product keeps its coverage, so the way back to live stays', (t
|
||||
radar.suspended = true;
|
||||
void clock.refresh();
|
||||
assert.equal(world.getState().supported, true);
|
||||
assert.deepEqual(
|
||||
world.coverage().marks,
|
||||
[0, 10].map((m) => Date.parse(time(m))),
|
||||
);
|
||||
assert.deepEqual(world.coverage(), {
|
||||
start: Date.parse(time(0)),
|
||||
end: Date.parse(time(10)),
|
||||
marks: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('new frames reach the world clock through refresh', (t) => {
|
||||
|
||||
@@ -57,11 +57,12 @@ const utc = (value) =>
|
||||
|
||||
/**
|
||||
* Move `world`, the world clock, as its timeline does: `play` toggles play
|
||||
* (from the start of the covered time when live), `latest` goes live, and
|
||||
* `step` (-1 or 1) goes to the previous or next mark, a weather frame among
|
||||
* them, as the timeline's arrow keys do.
|
||||
* (from the start of the covered time when live) and `latest` goes live.
|
||||
* `step` (-1 or 1) goes to the previous or next of `frames`, the weather
|
||||
* frames; the timeline does not mark them, so this is the row's own way to
|
||||
* step through them.
|
||||
*/
|
||||
function moveWorldTime(world, { play, latest, step }) {
|
||||
function moveWorldTime(world, frames, { play, latest, step }) {
|
||||
if (!world) return;
|
||||
if (play === true) {
|
||||
const state = world.getState();
|
||||
@@ -75,11 +76,14 @@ function moveWorldTime(world, { play, latest, step }) {
|
||||
if (latest === true) world.goLive();
|
||||
if (step === -1 || step === 1) {
|
||||
const at = world.getState().timeMs;
|
||||
const marks = world.coverage()?.marks ?? [];
|
||||
const times = frames
|
||||
.map((time) => Date.parse(time))
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
const to =
|
||||
step < 0
|
||||
? marks.findLast((mark) => mark < at)
|
||||
: marks.find((mark) => mark > at);
|
||||
? times.findLast((frame) => frame < at)
|
||||
: times.find((frame) => frame > at);
|
||||
if (to === undefined) return;
|
||||
world.pause();
|
||||
world.seek(to);
|
||||
@@ -485,8 +489,15 @@ export function createWeatherLayer({
|
||||
);
|
||||
}
|
||||
// Time is the world's: these move the world clock as its timeline
|
||||
// does, and every layer that follows it moves along.
|
||||
if (enabled) moveWorldTime(clock.world, params);
|
||||
// does, and every layer that follows it moves along. A step goes
|
||||
// through every shown observation's frames, and this row's own even
|
||||
// while it is suspended.
|
||||
if (enabled)
|
||||
moveWorldTime(
|
||||
clock.world,
|
||||
[...clock.getTimeline(), ...(manifest?.times ?? [])],
|
||||
params,
|
||||
);
|
||||
notify();
|
||||
},
|
||||
getParams() {
|
||||
|
||||
@@ -592,8 +592,12 @@ test('without a clock the layer keeps one of its own on the world clock and lets
|
||||
h.stages[0].finish();
|
||||
await update;
|
||||
assert.equal(worldClock.follows('weather-radar'), true);
|
||||
for (const time of times)
|
||||
assert.ok(worldClock.coverage().marks.includes(Date.parse(time)));
|
||||
// The frames span the timeline's time; they are not marks on it.
|
||||
assert.deepEqual(worldClock.coverage(), {
|
||||
start: Date.parse(times[0]),
|
||||
end: Date.parse(times.at(-1)),
|
||||
marks: [],
|
||||
});
|
||||
h.layer.setParams({ play: true });
|
||||
assert.equal(worldClock.isPlaying(), true);
|
||||
assert.equal(worldClock.getState().stepping, false);
|
||||
@@ -616,7 +620,7 @@ test('without a clock the layer keeps one of its own on the world clock and lets
|
||||
assert.ok(h.clears() >= 1);
|
||||
});
|
||||
|
||||
test('reduced motion offers no play; steps still move the world clock from mark to mark', async () => {
|
||||
test('reduced motion offers no play; steps still move the world clock from frame to frame', async () => {
|
||||
const h = layerHarness({ reducedMotion: true });
|
||||
const update = h.layer.update();
|
||||
await flush();
|
||||
@@ -626,8 +630,8 @@ test('reduced motion offers no play; steps still move the world clock from mark
|
||||
h.layer.getRowControls().chips.some((chip) => chip.id === 'play'),
|
||||
false,
|
||||
);
|
||||
// As the timeline's arrow keys do, a step back from live goes to the
|
||||
// newest frame, and the next one back to the frame before it.
|
||||
// A step back from live goes to the newest frame, and the next one back
|
||||
// to the frame before it.
|
||||
h.layer.setParams({ step: -1 });
|
||||
await flush();
|
||||
assert.equal(worldClock.now(), Date.parse(times[2]));
|
||||
@@ -656,7 +660,7 @@ test('a row steps forward and plays from a past time through the world clock', a
|
||||
assert.equal(
|
||||
worldClock.now(),
|
||||
Date.parse(times[2]),
|
||||
'no mark after the newest frame',
|
||||
'no frame after the newest',
|
||||
);
|
||||
worldClock.seek(Date.parse(times[1]));
|
||||
await flush();
|
||||
@@ -1829,8 +1833,8 @@ test('a row steps the world clock through every registered observation; missing
|
||||
.chips.filter(({ id }) =>
|
||||
['previous', 'play', 'next', 'latest'].includes(id),
|
||||
);
|
||||
// As the timeline's arrow keys do, a step back from live goes to the
|
||||
// newest frame, and the next one back to the frame before it.
|
||||
// A step back from live goes to the newest frame, and the next one back
|
||||
// to the frame before it.
|
||||
radar.layer.setParams({ step: -1 });
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, times[2]);
|
||||
|
||||
@@ -294,10 +294,10 @@ const scrub = (event) => {
|
||||
}
|
||||
store.scrub(Number(event.target.value), viewLength());
|
||||
};
|
||||
// Arrows go to the previous or next mark (a launch, a frame), or a step along
|
||||
// the track as shown; Page keys take a tenth of it; Home and End go to the
|
||||
// ends of all of it. Plus and minus zoom, and zero shows it all; with a
|
||||
// modifier they are the browser's own zoom.
|
||||
// Arrows go to the previous or next mark (a launch, a strong earthquake), or
|
||||
// a step along the track as shown where there is none; Page keys take a tenth
|
||||
// of it; Home and End go to the ends of all of it. Plus and minus zoom, and
|
||||
// zero shows it all; with a modifier they are the browser's own zoom.
|
||||
const ZOOM_KEYS = ['+', '=', '-', '0'];
|
||||
const onKey = (event) => {
|
||||
if (ZOOM_KEYS.includes(event.key)) {
|
||||
|
||||
Reference in new issue
Block a user