diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index c5c1aaa75..32e420a04 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -164,6 +164,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._denseStatus = 'ready'; console.log( `[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`, @@ -231,6 +232,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._denseCursor = 0; layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); } return { _abortActiveUpdates, diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 3db7d7471..b02f8371a 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -170,6 +170,7 @@ export function createIngestion({ layerState._coreStale = coreStale; layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._lastUpdate = Date.now(); layerState._lastPropagation = Date.now(); layerState._lastTrackingRefreshOutcome = { diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index d08f8b7a8..663541c9c 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -33,6 +33,7 @@ function ingestionFor(catalogText) { _catalogRevision: 0, _params: { catalog: 'core', showOrbits: false }, now: Date.now, + coverageChanged() {}, }; const parts = { orbits: { diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 896f2fe96..b12600c10 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -13,7 +13,8 @@ export function createLifecycle({ // their element sets. let releaseCoverage = null; const coverWorldTime = () => { - releaseCoverage ??= worldClock.register({ + if (releaseCoverage) return; + const release = worldClock.register({ id: 'satellites', coverage: () => { if (!layerState._catalog.size) return null; @@ -24,6 +25,10 @@ export function createLifecycle({ }; }, }); + // Registering tells the world's listeners, which may turn the layer off + // (or off and on, registering again) before this returns. + if (layerState._enabled && !releaseCoverage) releaseCoverage = release; + else release(); }; const uncoverWorldTime = () => { releaseCoverage?.(); @@ -67,6 +72,7 @@ export function createLifecycle({ layerState._denseStatus = 'idle'; layerState._denseError = null; layerState._catalogRevision++; + layerState.coverageChanged(); // Point primitives for satellite dots layerState._pointCollection = new Cesium.PointPrimitiveCollection(); @@ -185,6 +191,7 @@ export function createLifecycle({ layerState._denseStatus = 'idle'; layerState._denseError = null; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._rowControlsListener = null; layerState._count = 0; layerState._lastUpdate = null; diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 44e004476..8464fb275 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { worldTimeFor } from '../../time/worldClock.js'; +import { worldClock, worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -156,6 +156,10 @@ export function createState({ services }) { state.now = worldTimeFor('satellites'); + /** The catalog changed, so the time satellites can be shown at may have. */ + + state.coverageChanged = () => worldClock.coverageChanged(); + // Scratch variables state._scratchCartesian = new Cesium.Cartesian3(); diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index 9e2d5a96f..67973bf94 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -162,7 +162,34 @@ export function createWeatherClock({ throw new Error(`Duplicate weather product: ${product.id}`); const entry = { product, controller: null }; registry.set(product.id, entry); - releases.set(product.id, world.hold(product.id)); + // 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. + let unhold = null; + let uncover = null; + const release = () => { + uncover?.(); + unhold?.(); + uncover = unhold = null; + }; + releases.set(product.id, release); + unhold = world.hold(product.id); + if (!destroyed) + 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 } + : null; + }, + }); + if (destroyed) { + release(); + return () => {}; + } void api.refresh(); return () => { if (registry.get(product.id) !== entry) return; @@ -242,6 +269,8 @@ export function createWeatherClock({ }, refresh() { if (destroyed) return Promise.resolve(false); + // New frames, or products added or dropped. + world.coverageChanged(); if (!canPlay()) { stopPlaying(); cancelTimer(); @@ -262,7 +291,6 @@ export function createWeatherClock({ ++generation; cancelTimer(); unfollowWorld(); - uncover(); // The last weather clock leaves the world live, as a new one expects. if (driver.detach() === 0) world.goLive(); for (const release of releases.values()) release(); @@ -292,16 +320,6 @@ export function createWeatherClock({ else notify(); } const unfollowWorld = world.subscribe(followWorld); - // The observed frames this clock can show, for a host's timeline. - const uncover = world.register({ - id: 'weather', - coverage: () => { - const marks = getTimeline().map((time) => Date.parse(time)); - return marks.length - ? { start: marks[0], end: marks.at(-1), marks } - : null; - }, - }); driver = world.attachDriver({ play: () => (canPlay() ? void api.play() : false), pause: () => api.pause(), diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 9561c10c7..c803e5d72 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -436,9 +436,10 @@ test('a target superseded from a listener resolves false', async (t) => { assert.equal(radar.shown, time(10)); }); -test('the observed frames are the world clock coverage, while registered', (t) => { +test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => { const { clock, world } = harness(t); assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); const radar = product('radar', [0, 10]); const unregister = clock.register(radar); clock.register(product('lightning', [5])); @@ -447,7 +448,48 @@ test('the observed frames are the world clock coverage, while registered', (t) = end: Date.parse(time(10)), marks: [0, 5, 10].map((m) => Date.parse(time(m))), }); + assert.equal(world.getState().supported, true); unregister(); + assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]); clock.destroy(); assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); +}); + +test('a suspended product keeps its coverage, so the way back to live stays', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10]); + clock.register(radar); + 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))), + ); +}); + +test('new frames reach the world clock through refresh', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', []); + clock.register(radar); + const supported = []; + world.subscribe((state) => supported.push(state.supported)); + assert.equal(world.getState().supported, false); + radar.times = [time(0)]; + void clock.refresh(); + assert.equal(supported.at(-1), true); +}); + +test('a clock destroyed while it registers a product lets go of it', (t) => { + const { clock, world } = harness(t); + const off = world.subscribe(() => { + off(); + clock.destroy(); + }); + const unregister = clock.register(product('radar', [0, 10])); + unregister(); + assert.equal(world.follows('radar'), false); + assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); }); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 446318a43..b50d8f5a4 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -61,6 +61,25 @@ export function createWorldClock({ const followedIds = () => [ ...new Set([...following, ...[...holds.keys()].filter(isFollowed)]), ]; + // A host's coverage that throws counts as none, so it cannot break the + // listeners every state reaches. + const spanOf = (source) => { + let span; + try { + span = source.coverage(); + } catch (error) { + console.error(`World time coverage failed: ${source.id}`, error); + return null; + } + return span && span.end >= span.start ? span : null; + }; + // Whether a layer that moves with the clock has time to show: only then is + // there anything for a timeline to play. + const isSupported = () => { + for (const { source } of sources) + if (isFollowed(source.id) && spanOf(source)) return true; + return false; + }; const time = () => { if (live) return now(); if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate; @@ -73,17 +92,20 @@ export function createWorldClock({ playing, rate, following: followedIds(), + supported: isSupported(), }); // Each listener hears the state as it is when called. A change made from // inside a listener notifies everyone afresh, and the outer round stops, so // nobody is left holding a state that has since changed. + // A round's state is read once: any change made during it starts a new one. const notify = () => { const round = ++notifying; + let state = null; for (const listener of [...listeners]) { if (round !== notifying) return; if (!listeners.has(listener)) continue; try { - listener(getState()); + listener((state ??= getState())); } catch (error) { console.error('World clock listener failed', error); } @@ -270,16 +292,23 @@ export function createWorldClock({ // Each registration is its own, even under a shared id. const entry = { source }; sources.add(entry); - return () => sources.delete(entry); + changed(); + return () => { + if (sources.delete(entry)) changed(); + }; }, + /** A source's coverage changed (frames loaded, the layer paused). */ + 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. */ coverage() { let start = Infinity; let end = -Infinity; const marks = new Set(); for (const { source } of sources) { - const span = source.coverage(); - if (!span || !(span.end >= span.start)) continue; + 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); diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index a954f60fd..99d81084f 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -252,3 +252,38 @@ test('seek refuses a time no Date can hold', () => { clock.seek(-8.64e15); assert.equal(clock.now(), -8.64e15); }); + +test('only a followed layer with time to show supports the timeline', () => { + const clock = createWorldClock(fakeTime().options); + let span = null; + const states = []; + clock.subscribe((state) => states.push(state.supported)); + const off = clock.register({ id: 'satellites', coverage: () => span }); + span = { start: 0, end: 10 }; + clock.coverageChanged(); + assert.equal(clock.isSupported(), false, 'covered but not followed'); + clock.follow('satellites'); + assert.equal(clock.isSupported(), true); + assert.equal(states.at(-1), true); + span = null; + clock.coverageChanged(); + assert.equal(states.at(-1), false); + span = { start: 0, end: 10 }; + off(); + assert.equal(clock.isSupported(), false); + assert.equal(states.at(-1), false); +}); + +test('a coverage that throws counts as none', (t) => { + t.mock.method(console, 'error', () => {}); + const clock = createWorldClock(fakeTime().options); + clock.follow('broken'); + clock.register({ + id: 'broken', + coverage() { + throw new Error('no'); + }, + }); + assert.equal(clock.getState().supported, false); + assert.equal(clock.coverage(), null); +}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 8169982f3..1c39fbe66 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -3,7 +3,9 @@ import { onScopeDispose, shallowRef } from 'vue'; import { worldClock } from '../../time/worldClock.js'; /** - * The world clock as the timeline shows it: `{live, playing, timeMs}`. It + * The world clock as the timeline shows it: `{live, playing, timeMs, + * supported}`, where `supported` says a layer that moves with the clock has + * time to show. 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. */ @@ -13,6 +15,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { live: state.live, playing: state.playing, timeMs: state.timeMs, + supported: state.supported, }); const time = shallowRef(read()); onScopeDispose( diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 0fc35c5c2..2a10d06b3 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -8,7 +8,7 @@ import { createPinia } from 'pinia'; import { worldClock } from '../../../time/worldClock.js'; import WorldTimeline from './WorldTimeline.vue'; -test('the timeline shows away from live and plays, pauses and goes back', async (t) => { +function mount(t) { document.body.replaceChildren(); const app = createApp(WorldTimeline).use(createPinia()); app.mount(document.body.appendChild(document.createElement('div'))); @@ -16,11 +16,33 @@ test('the timeline shows away from live and plays, pauses and goes back', async app.unmount(); worldClock.goLive(); }); - const bar = () => document.getElementById('world-timeline'); + return () => document.getElementById('world-timeline'); +} + +// A layer that moves with the clock and has an hour of frames. +function supportingLayer(t, at) { + const release = worldClock.hold('test-radar'); + const unregister = worldClock.register({ + id: 'test-radar', + coverage: () => ({ start: at - 3_600_000, end: at }), + }); + t.after(() => { + unregister(); + release(); + }); + return () => { + unregister(); + release(); + }; +} + +test('the timeline shows away from live and plays, pauses and goes back', async (t) => { + const bar = mount(t); + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + supportingLayer(t, at); await nextTick(); assert.equal(bar(), null, 'hidden while live'); - const at = Date.UTC(2026, 9, 30, 6, 15, 42); worldClock.seek(at); await nextTick(); assert.ok(bar()); @@ -43,3 +65,17 @@ test('the timeline shows away from live and plays, pauses and goes back', async assert.equal(worldClock.isLive(), true); assert.equal(bar(), null); }); + +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); + worldClock.seek(at); + await nextTick(); + assert.equal(bar(), null, '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'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 33026f2a1..ea1da1fc3 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -8,7 +8,9 @@ import UiIcon from '../../kit/UiIcon.vue'; // The world time while it is away from live: what is playing, or where it // was paused, with play and pause on the left and the way back to live on -// the right. Live, the bar stays hidden. +// the right. It shows only while a layer that moves with the clock (weather +// radar, satellite or lightning, or one a host follows) is on with time to +// show; live, or with none on, the bar stays hidden. const store = useWorldTimeStore(); const { time } = storeToRefs(store); @@ -25,7 +27,7 @@ const readout = computed(() =>