diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index f94273d4e..b5af3c48a 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { gstime } from 'satellite.js'; +import { worldClock } from '../time/worldClock.js'; import { createModel } from '../layers/launches/model.js'; import rocketLaunchesLayer, { _setRocketMissionOverlayHostForTest, @@ -1228,7 +1229,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths rocketLaunchesLayer.init(viewer); initialized = true; await rocketLaunchesLayer.enable(); + 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] }); + assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline'); const entities = dataSources[0].entities.values; assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path'); @@ -1367,6 +1372,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths assert.equal(deselectedPublication[2][0].protected, false); await rocketLaunchesLayer.disable(); + assert.equal(worldClock.coverage(), null, 'off, launches show no time'); assert.deepEqual(hostCalls.slice(-4), [ ['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID], ['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false], @@ -1488,3 +1494,11 @@ test('missing payload details stay unknown without inventing mass or classificat const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW); assert.deepEqual(launch.payloads, []); }); + +test('away from live the feed keeps its own window of launches', (t) => { + t.after(() => worldClock.goLive()); + worldClock.seek(Date.now() - 20 * 86_400_000); + const net = new Date(Date.now() - 86_400_000).toISOString(); + const launches = normalizeRocketLaunches([{ id: 'recent', pad: { latitude: 1, longitude: 2 }, net }]); + assert.deepEqual(launches.map((launch) => launch.id), ['recent']); +}); diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index b0dc035cb..586376bb3 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -26,7 +26,7 @@ export function createControls({ state: layerState, services, parts, source }) { */ getAnalystRecords(maxCount = Infinity) { if (!layerState._enabled) return []; - const now = Date.now(); + const now = layerState.now(); const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null); const result = []; for (const launch of layerState._launches || []) { diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js index bd1529c0b..ebadde642 100644 --- a/src/layers/launches/ingestion.js +++ b/src/layers/launches/ingestion.js @@ -222,6 +222,7 @@ export function createIngestion({ layerState._lastUpdate = Date.now(); layerState._lastError = null; layerState._stale = stale === true; + layerState.coverageChanged(); } catch (error) { if (layerState._enabled && token === layerState._lifecycleToken) { layerState._lastError = error.message; diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index ca36a87ec..52999e0c6 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; +import { worldClock } from '../../time/worldClock.js'; const HOVER_PICK_INTERVAL_MS = 120; @@ -119,6 +120,21 @@ 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. + 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); + if (!marks.length) return null; + return { start: marks[0], end: marks.at(-1), marks }; + }, + }); const methods = { init(viewer) { @@ -194,6 +210,7 @@ export function createLifecycle({ } throw error; } + layerState.coverageChanged(); }, async _enableBody() { @@ -231,6 +248,7 @@ export function createLifecycle({ parts.overlays.clearMissionOverlaySources(); await parts.ingestion.restoreSatelliteDependency(); parts.panel.renderMissionPanel(); + layerState.coverageChanged(); }, async destroy(viewer) { diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index f9d712246..5697e07e8 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -175,7 +175,7 @@ export function createModel({ state: layerState, services, parts, source }) { * @returns {Array} */ - function normalizeRocketLaunches(payload, now = new Date(layerState.now())) { + function normalizeRocketLaunches(payload, now = new Date()) { const launches = Array.isArray(payload) ? payload : payload?.results; if (!Array.isArray(launches)) return []; const cutoff = now.getTime() - WINDOW_DAYS * 86400000; diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 469013da7..59d32bea7 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/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.orbitNow = worldTimeFor('satellites'); + /** The launches loaded changed, and with them the time they cover. */ + + state.coverageChanged = () => worldClock.coverageChanged(); + state._missionOrbitPatternRegistered = false; state._pathDistanceCache = new WeakMap(); diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index b02f8371a..289505cf7 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -170,7 +170,6 @@ 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 = { @@ -194,6 +193,7 @@ export function createIngestion({ // the rebuild must not block on that. void parts.tracking._reconcileTrackedSubjectContext(); parts.tracking._applyPendingTrackingRestore(); + layerState.coverageChanged(); } catch (e) { if (updateSignal.aborted || e?.name === 'AbortError') { throw new DOMException('Satellite update aborted', 'AbortError'); diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index b12600c10..27af96f29 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -10,30 +10,19 @@ export function createLifecycle({ }) { const { clearFocusTarget } = services.focus; // While on with a catalog, satellites can be shown at any time in reach of - // their element sets. - let releaseCoverage = null; - const coverWorldTime = () => { - if (releaseCoverage) return; - const release = worldClock.register({ - id: 'satellites', - coverage: () => { - if (!layerState._catalog.size) return null; - const now = worldClock.wallNow(); - return { - start: now - SATELLITE_COVERAGE_MS, - end: now + SATELLITE_COVERAGE_MS, - }; - }, - }); - // 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?.(); - releaseCoverage = null; - }; + // their element sets. Registered once; off, the layer has no time to show, + // and each step tells the clock once it has settled. + worldClock.register({ + id: 'satellites', + coverage: () => { + if (!layerState._enabled || !layerState._catalog.size) return null; + const now = worldClock.wallNow(); + return { + start: now - SATELLITE_COVERAGE_MS, + end: now + SATELLITE_COVERAGE_MS, + }; + }, + }); const { holdContinuousRender, releaseContinuousRender } = services.render; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -91,7 +80,6 @@ export function createLifecycle({ enable(viewer) { layerState._enabled = true; - coverWorldTime(); holdContinuousRender('satellites'); // per-frame animator (perf wave 2) if (layerState._pointCollection) layerState._pointCollection.show = @@ -120,13 +108,13 @@ export function createLifecycle({ ); } parts.tracking._applyPendingTrackingRestore(); + layerState.coverageChanged(); }, disable(viewer) { parts.catalog._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); layerState._enabled = false; - uncoverWorldTime(); releaseContinuousRender('satellites'); if (layerState._pointCollection) layerState._pointCollection.show = false; for (const path of layerState._orbitPaths.values()) @@ -148,13 +136,13 @@ export function createLifecycle({ layerState._preRenderListener(); layerState._preRenderListener = null; } + layerState.coverageChanged(); }, destroy(viewer) { parts.catalog._abortActiveUpdates(); releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; - uncoverWorldTime(); parts.tracking._clearTracking(); parts.tracking._cancelPendingTrackingRestore(); if (layerState._clickHandler) { @@ -191,7 +179,6 @@ export function createLifecycle({ layerState._denseStatus = 'idle'; layerState._denseError = null; layerState._catalogRevision++; - layerState.coverageChanged(); layerState._rowControlsListener = null; layerState._count = 0; layerState._lastUpdate = null; @@ -207,6 +194,7 @@ export function createLifecycle({ failedGroups: [], }; layerState._viewer = null; + layerState.coverageChanged(); }, }; diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index a6bfef8fe..a7eecbbf2 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -30,6 +30,13 @@ export const ORBIT_PATH_STEPS = 180; export const POSITION_UPDATE_MS = 1000; /** How far either side of now SGP4 on current element sets stays meaningful. */ export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000; + +/** + * How far the world time may move from when an orbit ring was drawn before it + * is drawn again: the Earth's turn is followed exactly, but the orbit's plane + * drifts a few degrees a day. + */ +export const ORBIT_REBAKE_MS = 30 * 60_000; // re-propagate every 1s (SGP4 is smooth at this rate) export const RING_ROTATION_MS = 1000; diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js index e722ce7c2..98da8d3d7 100644 --- a/src/layers/satellites/rendering.js +++ b/src/layers/satellites/rendering.js @@ -1,6 +1,11 @@ import * as Cesium from 'cesium'; import { gstime } from 'satellite.js'; -import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js'; +import { + ISS_NORAD, + ORBIT_REBAKE_MS, + POSITION_UPDATE_MS, + RING_ROTATION_MS, +} from './policy.js'; export function createRendering({ state: layerState, @@ -75,6 +80,8 @@ export function createRendering({ layerState._orbitPaths.set(noradId, { primitive, + color: pathColor, + bakeMs: bakeDate.getTime(), // Same Date object as computeOrbitPath's internal fixedGmst → identical // GMST value (gstime is pure), so delta starts at exactly 0 and the // initial identity modelMatrix is correct until the first ring tick. @@ -113,8 +120,15 @@ export function createRendering({ function _updateOrbitPathRotations(nowDate) { if (layerState._orbitPaths.size === 0) return; - for (const path of layerState._orbitPaths.values()) { + for (const [noradId, path] of [...layerState._orbitPaths]) { if (!path.primitive) continue; + // The rotation keeps the ring under the dot for minutes; the plane + // itself precesses, so a world time moved far from the bake re-bakes. + if (Math.abs(nowDate.getTime() - path.bakeMs) > ORBIT_REBAKE_MS) { + _hideOrbitPath(noradId); + _showOrbitPath(noradId, path.color); + continue; + } parts.orbits.orbitFrameModelMatrix( path.gmstAtBake, nowDate, diff --git a/src/time/worldClock.js b/src/time/worldClock.js index b50d8f5a4..a875ddd2f 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -6,8 +6,10 @@ * Live is a mode, not a timestamp: while live the clock is the wall clock and * every layer behaves as it would reading `Date.now()`. Away from live the * clock holds a cursor, fixed or playing. Only the layers that follow the clock - * (`follow(id)`) move with the cursor; the rest keep reading the live edge - * through `timeFor(id)`, so a host opts layers in one at a time. + * move with the cursor; the rest keep reading the live edge through + * `timeFor(id)`. On the page's clock the layers whose data has a time of its + * own (the sky, satellites, launches and weather) follow it; live feeds such + * as flights and vessels have no time to move to and 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 @@ -28,7 +30,9 @@ export const WORLD_CLOCK_TICK_MS = 250; /** * @param {{now?: () => number, monotonicNow?: () => number, * setInterval?: typeof globalThis.setInterval, - * clearInterval?: typeof globalThis.clearInterval}} [options] + * clearInterval?: typeof globalThis.clearInterval, + * follow?: Iterable}} [options] `follow` lists the layers that + * move with the cursor from the start. */ export function createWorldClock({ // Read at each call, so a stand-in for Date.now (a test's, a harness's) @@ -37,10 +41,11 @@ export function createWorldClock({ monotonicNow = () => globalThis.performance.now(), setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval, + follow = [], } = {}) { const listeners = new Set(); // Layers a host follows, and layers a part of the app holds following. - const following = new Set(); + const following = new Set(follow); const holds = new Map(); const readers = new Map(); const sources = new Set(); @@ -330,8 +335,15 @@ export function createWorldClock({ return api; } -/** The page's world clock. Layers, the sky and a host all read this one. */ -export const worldClock = createWorldClock(); +/** + * The page's world clock. Layers, the sky and a host all read this one. The + * sky, satellites (and the launched missions flying on their time) and + * launches have a time of their own and move with it; weather holds each + * product it shows. + */ +export const worldClock = createWorldClock({ + follow: ['sky', 'satellites', 'rocket-launches'], +}); /** The time layer `id` draws at on the page's clock. */ export const worldTimeFor = (id) => worldClock.timeFor(id); diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index 99d81084f..c116ce27c 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -55,6 +55,24 @@ test('away from live only following layers move with the cursor', () => { assert.equal(satellites(), t.wall); }); +test('layers named at creation follow from the start', () => { + const t = fakeTime(); + const clock = createWorldClock({ ...t.options, follow: ['satellites'] }); + clock.seek(t.wall - 1_000); + assert.equal(clock.timeFor('satellites')(), t.wall - 1_000); + assert.equal(clock.timeFor('flights')(), t.wall); + clock.follow('satellites', false); + assert.equal(clock.follows('satellites'), false); +}); + +test('the page clock moves the layers with a time of their own', async () => { + const { worldClock } = await import('./worldClock.js'); + for (const id of ['sky', 'satellites', 'rocket-launches']) + assert.equal(worldClock.follows(id), true, id); + for (const id of ['flights', 'military', 'vessels', 'transit']) + assert.equal(worldClock.follows(id), false, id); +}); + test('continuous play advances the cursor at the rate and pause holds it', () => { const t = fakeTime(); const clock = createWorldClock(t.options); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index ea1da1fc3..91b6676db 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -8,9 +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. 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. +// the right. It shows only while a layer with a time of its own (weather, +// satellites, launches, or one a host follows) is on with time to show; live, +// or with only live feeds such as flights on, the bar stays hidden. const store = useWorldTimeStore(); const { time } = storeToRefs(store); diff --git a/src/voice/analystAccessors.test.mjs b/src/voice/analystAccessors.test.mjs index bd8ac611a..4d9abe453 100644 --- a/src/voice/analystAccessors.test.mjs +++ b/src/voice/analystAccessors.test.mjs @@ -74,9 +74,11 @@ test('bikeshare stations join coordinates from station information', () => { assert.deepEqual(methods.getAnalystRecords(), []); }); -test('launches carry time and hours until launch', () => { - const at = Date.now() + 5 * 3_600_000; +test('launches carry time and hours until launch at the world time', () => { + const now = Date.UTC(2026, 10, 1); + const at = now + 5 * 3_600_000; const state = { + now: () => now, _enabled: true, _launches: [ {