From 3534398771b9bc192543357c9396a638b62c7d97 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 22:53:51 -0800 Subject: [PATCH] Move satellites, launches and the sky with the world time Layers whose data has a time of its own follow the page's world clock from the start; live feeds such as flights, military, vessels and transit stay live. Launches register the span of the launches loaded, so turning them on brings up the timeline. Each timed layer registers its coverage once and reports none while off, telling the clock after each step has settled. The launch feed keeps its own window, and an orbit ring is drawn again once the world time has moved far from when it was drawn. --- src/data/rocketLaunches.test.mjs | 14 +++++++++ src/layers/launches/controls.js | 2 +- src/layers/launches/ingestion.js | 1 + src/layers/launches/lifecycle.js | 18 +++++++++++ src/layers/launches/model.js | 2 +- src/layers/launches/state.js | 6 +++- src/layers/satellites/ingestion.js | 2 +- src/layers/satellites/lifecycle.js | 44 ++++++++++----------------- src/layers/satellites/policy.js | 7 +++++ src/layers/satellites/rendering.js | 18 +++++++++-- src/time/worldClock.js | 24 +++++++++++---- src/time/worldClock.test.mjs | 18 +++++++++++ src/ui/views/panels/WorldTimeline.vue | 6 ++-- src/voice/analystAccessors.test.mjs | 6 ++-- 14 files changed, 123 insertions(+), 45 deletions(-) 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: [ {