diff --git a/changelog.d/0000-timeline-one-control-and-marks.md b/changelog.d/0000-timeline-one-control-and-marks.md index fdf384488..6fa6ca5be 100644 --- a/changelog.d/0000-timeline-one-control-and-marks.md +++ b/changelog.d/0000-timeline-one-control-and-marks.md @@ -1,3 +1,13 @@ +- The timeline marks only events: launches, and earthquakes of magnitude 6 + and up while the Earthquakes layer is on (about one every two days + worldwide). It no longer draws a tick for every weather frame, which came + every few minutes and made the track a wall of dashes; the weather frames + still set the time the timeline covers, and Play still waits at each next + frame until it loads. With no mark ahead, the arrow keys step along the + track. The map still draws every earthquake, and an earthquake layer never + widens the timeline beyond its strong quakes' own times. A weather row's + `step` param goes to the previous or next weather frame (samehkhamis, + #0000). - The main timeline is the only time control. The Weather panel no longer has a slider, step buttons, Latest or Play of its own; it shows the frame time instead: the newest frame and its age while live, the timeline time diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26e6a5e3b..37b8b6615 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1781,7 +1781,11 @@ Earthquake rendering is exposed through `./layers/earthquakes`. The layer owns its entities and request lifecycle; the application supplies the overlay host and snapshot source. The standalone adapter keeps the existing USGS daily feed, M2.5+ filtering, static discs, magnitude labels and analyst records. Disabling or -destroying the layer cancels pending work and ignores late results. +destroying the layer cancels pending work and ignores late results. While on, +the layer registers its quakes of magnitude 6 and up +(`EARTHQUAKE_MARK_MAGNITUDE`) with the world clock as timeline marks, spanning +only their own times, and none while off; the map still draws every M2.5+ +quake. ## Rendered-mesh floor readings — November 9, 2026 @@ -8533,9 +8537,13 @@ Weather combines the GFS/ECMWF surface forecast with three observation layers: NOAA Rain radar (CONUS), Satellite clouds (regional GOES or slower global infrared), and 15-minute lightning density (Americas and Pacific). One transient observed clock moves all enabled observation rows with the world clock; the world -timeline is the only time control. Each product's advertised times are marks on -that timeline, which its arrow keys step to; each product selects its nearest -frame at or before the world time, never a future frame or an interpolation. +timeline is the only time control. Each product's advertised times span time on +that timeline but are not marks on it: the timeline marks only events (launches, +and earthquakes of magnitude 6 and up while that layer is on), so its arrow keys +step to those, or along the track where there are none. A weather row's `step` +param goes to the previous or next weather frame. Each product selects its +nearest frame at or before the world time, never a future frame or an +interpolation. The maximum gap is 30 minutes for radar, regional infrared and lightning, and 3 hours for hourly global infrared, but never less than one and a half of the feed's median frame spacing over its latest frames, so a regional feed whose diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b819fa2c2..95e87d082 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1762,7 +1762,8 @@ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", - "src/layers/earthquakes/records.js" + "src/layers/earthquakes/records.js", + "src/time/worldClock.js" ], "external": ["cesium"] }, diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index 0d522fcb9..66d8f3d70 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { worldClock } from '../../time/worldClock.js'; import { EARTHQUAKE_OVERLAY_SOURCE_ID, EARTHQUAKE_OVERLAY_COHORT_LIMIT, @@ -7,6 +8,7 @@ import { createEarthquakeOverlayEntry, selectEarthquakeOverlayCohort, mapAnalystRecord, + earthquakeCoverage, } from './model.js'; export * from './model.js'; export { createUsgsEarthquakeSource } from './source.js'; @@ -27,6 +29,19 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { let _lastUpdate = null; let _lastError = null; let _enabled = false; + // The quakes last drawn, as the timeline reads them ({ mag, time }). + let _events = []; + let _uncover = null; + // The marks last told to the clock, so a refresh with the same strong + // quakes does not tell it again. + let _marksKey = null; + const coverage = () => (_enabled ? earthquakeCoverage(_events) : null); + const coverageChanged = () => { + const key = coverage()?.marks.join() ?? null; + if (key === _marksKey) return; + _marksKey = key; + worldClock.coverageChanged(); + }; const layer = { id: 'earthquakes', @@ -41,6 +56,10 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { _dataSource = new Cesium.CustomDataSource('earthquakes'); _dataSource.show = false; viewer.dataSources.add(_dataSource); + // While on, the strong quakes are marks on the world clock's timeline. + // Registered once; off, the layer has no time to show. + _marksKey = null; + _uncover = worldClock.register({ id: 'earthquakes', coverage }); _count = 0; _lastUpdate = null; _lastError = null; @@ -55,6 +74,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { // layer has no per-frame animator to keep the render loop alive for. if (_dataSource) _dataSource.show = true; overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, true); + coverageChanged(); }, disable(viewer) { @@ -64,6 +84,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { if (_dataSource) _dataSource.show = false; overlayHost.clearSource(EARTHQUAKE_OVERLAY_SOURCE_ID); overlayHost.setVisible(EARTHQUAKE_OVERLAY_SOURCE_ID, false); + coverageChanged(); }, async update(viewer) { @@ -77,6 +98,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { return false; const nextEntities = []; + const nextEvents = []; let count = 0; const overlayEntries = []; @@ -91,6 +113,7 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { time, } of rows) { count++; + nextEvents.push({ mag, time }); const baseRadius = Math.pow(2, mag) * 1000; const color = depthColor(depthKm || 0); const isSignificant = mag >= 5.0; @@ -137,6 +160,8 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { _dataSource.entities.removeAll(); for (const entity of nextEntities) _dataSource.entities.add(entity); + _events = nextEvents; + coverageChanged(); if (_enabled) { overlayHost.setEntries( EARTHQUAKE_OVERLAY_SOURCE_ID, @@ -176,6 +201,10 @@ export function createEarthquakesLayer({ source, overlayHost } = {}) { viewer.dataSources.remove(_dataSource, true); _dataSource = null; } + _events = []; + _uncover?.(); + _uncover = null; + _marksKey = null; _count = 0; _lastUpdate = null; _lastError = null; diff --git a/src/layers/earthquakes/model.js b/src/layers/earthquakes/model.js index c88063b7b..1c2a68836 100644 --- a/src/layers/earthquakes/model.js +++ b/src/layers/earthquakes/model.js @@ -3,6 +3,34 @@ export const EARTHQUAKE_OVERLAY_SOURCE_ID = 'earthquakes'; export const EARTHQUAKE_OVERLAY_COHORT_LIMIT = 96; export const EARTHQUAKE_OVERLAY_COLLISION_CAPACITY = 48; +/** + * The smallest magnitude the timeline marks. Most quakes are small shakes; + * M6 and up come about once every two days worldwide, so each is an event + * worth stepping to. The map still draws every quake. + */ +export const EARTHQUAKE_MARK_MAGNITUDE = 6; + +/** + * The time a world clock's timeline can show for `events` (`{mag, time}`, + * time in ms): the span of the quakes of EARTHQUAKE_MARK_MAGNITUDE and up, + * each one a mark, or null with none, so the layer never stretches the + * timeline past what its feed covers. + * @returns {{start: number, end: number, marks: number[]} | null} + */ +export function earthquakeCoverage(events) { + const marks = [ + ...new Set( + events + .filter( + ({ mag, time }) => + mag >= EARTHQUAKE_MARK_MAGNITUDE && Number.isFinite(time), + ) + .map(({ time }) => time), + ), + ].sort((a, b) => a - b); + return marks.length ? { start: marks[0], end: marks.at(-1), marks } : null; +} + /** * Color by depth: * - Shallow (<70km): Red diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs index 9951935de..a86a797a1 100644 --- a/src/layers/earthquakes/ownership.test.mjs +++ b/src/layers/earthquakes/ownership.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createEarthquakesLayer } from './index.js'; import { createUsgsEarthquakeSource } from './source.js'; +import { EARTHQUAKE_MARK_MAGNITUDE } from './model.js'; +import { worldClock } from '../../time/worldClock.js'; function harness(source) { const sources = []; const events = []; @@ -88,3 +90,40 @@ test('USGS body completion honors cancellation even with an uncooperative transp name: 'AbortError', }); }); +test('the timeline marks only strong quakes, while the layer is on', async () => { + assert.equal(EARTHQUAKE_MARK_MAGNITUDE, 6); + const rows = [ + { ...row, stableId: 'small', mag: 4.5, time: 1_000 }, + { ...row, stableId: 'strong', mag: 6, time: 5_000 }, + { ...row, stableId: 'great', mag: 7.2, time: 3_000 }, + { ...row, stableId: 'untimed', mag: 6.5, time: null }, + ]; + let snapshot = rows; + const h = harness({ getSnapshot: async () => snapshot }); + assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show'); + let changes = 0; + const unsubscribe = worldClock.subscribe(() => changes++); + await h.layer.update(h.viewer); + // Every quake is drawn; only M6 and up are marks, spanning their own time. + assert.equal(h.sources[0].entities.values.length, 4); + assert.deepEqual(worldClock.coverage(), { + start: 3_000, + end: 5_000, + marks: [3_000, 5_000], + }); + const told = changes; + await h.layer.update(h.viewer); + assert.equal(changes, told, 'the same strong quakes are not told again'); + snapshot = rows.slice(0, 1); + await h.layer.update(h.viewer); + assert.equal(worldClock.coverage(), null, 'small shakes alone cover no time'); + snapshot = rows; + await h.layer.update(h.viewer); + h.layer.disable(h.viewer); + assert.equal(worldClock.coverage(), null, 'off, no time to show'); + h.layer.enable(h.viewer); + assert.deepEqual(worldClock.coverage().marks, [3_000, 5_000]); + h.layer.destroy(); + assert.equal(worldClock.coverage(), null); + unsubscribe(); +}); diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index 672415973..f46d5fc3d 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -198,11 +198,13 @@ export function createWeatherClock({ throw new Error(`Duplicate weather product: ${product.id}`); const entry = { product, controller: null }; registry.set(product.id, entry); - // The product moves with the world clock, and its frames are time the - // world clock's timeline can show while it is on, suspended or not, so - // the way back to live stays on screen. Both calls notify the world's - // listeners, which may destroy this clock: the release is in place - // first, and anything acquired after a destroy is let go at once. + // The product moves with the world clock, and the span of its frames is + // time the world clock's timeline can show while it is on, suspended or + // not, so the way back to live stays on screen. The frames are not + // marks: they come every few minutes, and the timeline marks only + // events. Both calls notify the world's listeners, which may destroy + // this clock: the release is in place first, and anything acquired + // after a destroy is let go at once. let unhold = null; let uncover = null; const release = () => { @@ -216,9 +218,12 @@ export function createWeatherClock({ uncover = world.register({ id: product.id, coverage: () => { - const marks = timesFor(product).map((time) => Date.parse(time)); - return marks.length - ? { start: marks[0], end: marks.at(-1), marks } + const times = timesFor(product); + return times.length + ? { + start: Date.parse(times[0]), + end: Date.parse(times.at(-1)), + } : null; }, }); diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 5a5c36957..1041f69d4 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -367,7 +367,7 @@ test('weather clocks sharing a world: the last one destroyed leaves it live', () assert.equal(world.isPlaying(), false); }); -test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => { +test('each product’s frames span world clock coverage, with no marks, and support the timeline while shown', (t) => { const { clock, world } = harness(t); assert.equal(world.coverage(), null); assert.equal(world.getState().supported, false); @@ -377,11 +377,15 @@ test('each product’s frames are world clock coverage, and support the timeline assert.deepEqual(world.coverage(), { start: Date.parse(time(0)), end: Date.parse(time(10)), - marks: [0, 5, 10].map((m) => Date.parse(time(m))), + marks: [], }); assert.equal(world.getState().supported, true); unregister(); - assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]); + assert.deepEqual(world.coverage(), { + start: Date.parse(time(5)), + end: Date.parse(time(5)), + marks: [], + }); clock.destroy(); assert.equal(world.coverage(), null); assert.equal(world.getState().supported, false); @@ -394,10 +398,11 @@ test('a suspended product keeps its coverage, so the way back to live stays', (t radar.suspended = true; void clock.refresh(); assert.equal(world.getState().supported, true); - assert.deepEqual( - world.coverage().marks, - [0, 10].map((m) => Date.parse(time(m))), - ); + assert.deepEqual(world.coverage(), { + start: Date.parse(time(0)), + end: Date.parse(time(10)), + marks: [], + }); }); test('new frames reach the world clock through refresh', (t) => { diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 9a014bbc6..ce2f70c5c 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -57,11 +57,12 @@ const utc = (value) => /** * Move `world`, the world clock, as its timeline does: `play` toggles play - * (from the start of the covered time when live), `latest` goes live, and - * `step` (-1 or 1) goes to the previous or next mark, a weather frame among - * them, as the timeline's arrow keys do. + * (from the start of the covered time when live) and `latest` goes live. + * `step` (-1 or 1) goes to the previous or next of `frames`, the weather + * frames; the timeline does not mark them, so this is the row's own way to + * step through them. */ -function moveWorldTime(world, { play, latest, step }) { +function moveWorldTime(world, frames, { play, latest, step }) { if (!world) return; if (play === true) { const state = world.getState(); @@ -75,11 +76,14 @@ function moveWorldTime(world, { play, latest, step }) { if (latest === true) world.goLive(); if (step === -1 || step === 1) { const at = world.getState().timeMs; - const marks = world.coverage()?.marks ?? []; + const times = frames + .map((time) => Date.parse(time)) + .filter(Number.isFinite) + .sort((a, b) => a - b); const to = step < 0 - ? marks.findLast((mark) => mark < at) - : marks.find((mark) => mark > at); + ? times.findLast((frame) => frame < at) + : times.find((frame) => frame > at); if (to === undefined) return; world.pause(); world.seek(to); @@ -485,8 +489,15 @@ export function createWeatherLayer({ ); } // Time is the world's: these move the world clock as its timeline - // does, and every layer that follows it moves along. - if (enabled) moveWorldTime(clock.world, params); + // does, and every layer that follows it moves along. A step goes + // through every shown observation's frames, and this row's own even + // while it is suspended. + if (enabled) + moveWorldTime( + clock.world, + [...clock.getTimeline(), ...(manifest?.times ?? [])], + params, + ); notify(); }, getParams() { diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 0748d69df..296acbb30 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -592,8 +592,12 @@ test('without a clock the layer keeps one of its own on the world clock and lets h.stages[0].finish(); await update; assert.equal(worldClock.follows('weather-radar'), true); - for (const time of times) - assert.ok(worldClock.coverage().marks.includes(Date.parse(time))); + // The frames span the timeline's time; they are not marks on it. + assert.deepEqual(worldClock.coverage(), { + start: Date.parse(times[0]), + end: Date.parse(times.at(-1)), + marks: [], + }); h.layer.setParams({ play: true }); assert.equal(worldClock.isPlaying(), true); assert.equal(worldClock.getState().stepping, false); @@ -616,7 +620,7 @@ test('without a clock the layer keeps one of its own on the world clock and lets assert.ok(h.clears() >= 1); }); -test('reduced motion offers no play; steps still move the world clock from mark to mark', async () => { +test('reduced motion offers no play; steps still move the world clock from frame to frame', async () => { const h = layerHarness({ reducedMotion: true }); const update = h.layer.update(); await flush(); @@ -626,8 +630,8 @@ test('reduced motion offers no play; steps still move the world clock from mark h.layer.getRowControls().chips.some((chip) => chip.id === 'play'), false, ); - // As the timeline's arrow keys do, a step back from live goes to the - // newest frame, and the next one back to the frame before it. + // A step back from live goes to the newest frame, and the next one back + // to the frame before it. h.layer.setParams({ step: -1 }); await flush(); assert.equal(worldClock.now(), Date.parse(times[2])); @@ -656,7 +660,7 @@ test('a row steps forward and plays from a past time through the world clock', a assert.equal( worldClock.now(), Date.parse(times[2]), - 'no mark after the newest frame', + 'no frame after the newest', ); worldClock.seek(Date.parse(times[1])); await flush(); @@ -1829,8 +1833,8 @@ test('a row steps the world clock through every registered observation; missing .chips.filter(({ id }) => ['previous', 'play', 'next', 'latest'].includes(id), ); - // As the timeline's arrow keys do, a step back from live goes to the - // newest frame, and the next one back to the frame before it. + // A step back from live goes to the newest frame, and the next one back + // to the frame before it. radar.layer.setParams({ step: -1 }); await flush(); assert.equal(clock.getState().target, times[2]); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 866380638..2c8e2981b 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -294,10 +294,10 @@ const scrub = (event) => { } store.scrub(Number(event.target.value), viewLength()); }; -// Arrows go to the previous or next mark (a launch, a frame), or a step along -// the track as shown; Page keys take a tenth of it; Home and End go to the -// ends of all of it. Plus and minus zoom, and zero shows it all; with a -// modifier they are the browser's own zoom. +// Arrows go to the previous or next mark (a launch, a strong earthquake), or +// a step along the track as shown where there is none; Page keys take a tenth +// of it; Home and End go to the ends of all of it. Plus and minus zoom, and +// zero shows it all; with a modifier they are the browser's own zoom. const ZOOM_KEYS = ['+', '=', '-', '0']; const onKey = (event) => { if (ZOOM_KEYS.includes(event.key)) {