From 0aa54dfa97d9f2eb09ac062bfaa52689163d933f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Mon, 2 Nov 2026 07:21:01 -0800 Subject: [PATCH] Bring launches up as the world time passes them, and play the whole track Away from live, a launch shows from its lift-off on: its marker, label, paths and orbit stay hidden while the world time is before it, so scrubbing or playing brings the launches up in the order they flew. The selected mission stays whenever it flies, and live shows every launch as before. Play on the timeline runs the whole track in about a minute, from its start when at live, and runs the clock itself past weather's own replay. It goes live when it reaches now, or holds at an end that lies ahead of now. Going live puts the rate back to 1. --- src/layers/launches/orbitRendering.js | 3 + src/layers/launches/overlays.js | 5 +- src/layers/launches/rendering.js | 47 ++++++++-- src/layers/launches/state.js | 20 ++++ src/layers/launches/worldTime.test.mjs | 102 +++++++++++++++++++++ src/time/worldClock.js | 14 ++- src/time/worldClock.test.mjs | 14 +++ src/ui/stores/worldTime.js | 76 +++++++++++++-- src/ui/views/panels/WorldTimeline.test.mjs | 70 ++++++++++++++ 9 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 src/layers/launches/worldTime.test.mjs diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js index b9a6cab09..f02494fff 100644 --- a/src/layers/launches/orbitRendering.js +++ b/src/layers/launches/orbitRendering.js @@ -67,6 +67,9 @@ export function createOrbitRendering({ return Boolean( layerState._enabled && layerState._dataSource?.show && + !layerState.launchHidden( + layerState._missionOverlayRecords.get(launchId)?.launch, + ) && (!layerState._selectedLaunchId || layerState._selectedLaunchId === launchId), ); diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js index 4b246e702..50c9f91e2 100644 --- a/src/layers/launches/overlays.js +++ b/src/layers/launches/overlays.js @@ -411,8 +411,11 @@ export function createOverlays({ state: layerState, services, parts, source }) { false, ); layerState._selectedMissionOverlayTimeText = null; + const records = [...layerState._missionOverlayRecords.values()].filter( + (record) => !layerState.launchHidden(record.launch), + ); const entries = selectRocketMissionMarkerOverlayCohort( - Array.from(layerState._missionOverlayRecords.values(), (record) => { + records.map((record) => { const entry = createRocketMissionMarkerOverlayEntry( record.launch, record.anchorPosition, diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 52eb39961..2ea0b86d5 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -22,6 +22,25 @@ export function createRendering({ if (current !== next) graphic.show = next; } + // Away from live, a launch shows from its lift-off on, so scrubbing the + // world time forward brings the launches up in the order they flew. The + // selected mission stays, whenever it flies. + function syncPendingLaunches() { + const pending = new Set(); + for (const { launch } of layerState._missionOverlayRecords.values()) { + if (layerState.launchHidden(launch)) pending.add(launch.id); + } + const previous = layerState._pendingLaunchIds; + if ( + pending.size === previous.size && + [...pending].every((id) => previous.has(id)) + ) + return; + layerState._pendingLaunchIds = pending; + parts.overlays.syncMissionOverlayEntries(); + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + } + function updateMissionFrame() { if ( !layerState._enabled || @@ -31,6 +50,7 @@ export function createRendering({ parts.overlays.hideReplayVehicleOverlay(); return; } + syncPendingLaunches(); parts.panel.updateMissionTelemetry(); const time = Cesium.JulianDate.now(layerState._declutterTime); layerState._declutterOccluder.cameraPosition = @@ -39,21 +59,28 @@ export function createRendering({ parts.overlays.refreshSelectedMissionOverlayText(); for (const entity of layerState._dataSource.entities.values) { if (!entity.show) continue; - if (!entity.position) continue; const id = parts.selection.entityLaunchId(entity); if (!id) continue; + const pending = layerState._pendingLaunchIds.has(id); + if (entity.polyline) + setGraphicVisibility(entity.polyline, !pending, time); + if (!entity.position) continue; const position = entity.position.getValue(time); - let horizonVisible = Boolean( - position && layerState._declutterOccluder.isPointVisible(position), - ); + let horizonVisible = + !pending && + Boolean( + position && layerState._declutterOccluder.isPointVisible(position), + ); const launchAnchor = entity.id.startsWith('rocket-launch:'); if (launchAnchor) { - horizonVisible = parts.overlays.missionAnchorVisible( - layerState._viewer.scene.camera.positionWC, - position, - id, - layerState._selectedLaunchId, - ); + horizonVisible = + !pending && + parts.overlays.missionAnchorVisible( + layerState._viewer.scene.camera.positionWC, + position, + id, + layerState._selectedLaunchId, + ); // Keep entity.show reserved for mission-selection isolation. Horizon // state is applied to the individual graphics below so a rear-side // anchor remains eligible for reevaluation as the camera moves. diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index 59d32bea7..a2f0dfa66 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -160,6 +160,26 @@ export function createState({ services }) { state.coverageChanged = () => worldClock.coverageChanged(); + /** + * Whether a launch has yet to lift off at the world time. While live none + * has, so the layer shows every launch in its window as it always did. + */ + + state.launchPending = (launch) => { + if (worldClock.isLive()) return false; + const launchMs = Date.parse(launch?.launchTime); + return Number.isFinite(launchMs) && launchMs > state.now(); + }; + + /** Whether a launch is hidden for lifting off after the world time. */ + + state.launchHidden = (launch) => + launch?.id !== state._selectedLaunchId && state.launchPending(launch); + + /** The launches hidden at the last frame, by id, to spot a change. */ + + state._pendingLaunchIds = new Set(); + state._missionOrbitPatternRegistered = false; state._pathDistanceCache = new WeakMap(); diff --git a/src/layers/launches/worldTime.test.mjs b/src/layers/launches/worldTime.test.mjs new file mode 100644 index 000000000..d351c92b7 --- /dev/null +++ b/src/layers/launches/worldTime.test.mjs @@ -0,0 +1,102 @@ +// Away from live, a launch shows from its lift-off on, so scrubbing the world +// time brings the launches up in the order they flew. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { worldClock } from '../../time/worldClock.js'; +import { createState } from './state.js'; +import { createRendering } from './rendering.js'; +import { createSelection } from './selection.js'; + +const noop = () => {}; +const LAUNCH_MS = Date.UTC(2026, 9, 20, 12); + +function scene(t) { + t.after(() => worldClock.goLive()); + const state = createState({ + services: { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + }, + }); + const launch = { + id: 'ceres-1', + launchTime: new Date(LAUNCH_MS).toISOString(), + }; + state._enabled = true; + state._dataSource = new Cesium.CustomDataSource('launches'); + state._viewer = { + scene: { camera: { positionWC: new Cesium.Cartesian3(2e7, 0, 0) } }, + }; + state._missionOverlayRecords.set(launch.id, { launch }); + const position = Cesium.Cartesian3.fromDegrees(0, 0); + const anchor = state._dataSource.entities.add({ + id: `rocket-launch:${launch.id}`, + position, + point: { pixelSize: 5 }, + }); + const path = state._dataSource.entities.add({ + id: `rocket-transfer:${launch.id}`, + polyline: { positions: [position, Cesium.Cartesian3.fromDegrees(1, 1)] }, + }); + const syncs = { labels: 0, orbits: 0 }; + const parts = { + panel: { updateMissionTelemetry: noop }, + overlays: { + hideReplayVehicleOverlay: noop, + updateReplayVehicleOverlay: noop, + refreshSelectedMissionOverlayText: noop, + missionAnchorVisible: () => true, + syncMissionOverlayEntries: () => syncs.labels++, + }, + orbitRendering: { + syncMissionOrbitPrimitiveVisibility: () => syncs.orbits++, + }, + }; + const services = { + geometry: { getKeyholeGeometry: noop }, + satellites: {}, + }; + parts.selection = createSelection({ state, services, parts }); + const rendering = createRendering({ state, services, parts }); + const shown = () => { + rendering.updateMissionFrame(); + const time = Cesium.JulianDate.now(); + return { + marker: anchor.point.show.getValue(time), + path: path.polyline.show?.getValue(time) ?? true, + }; + }; + return { state, launch, syncs, shown }; +} + +test('live shows every launch in the window, lifted off or not', (t) => { + const { state, launch, shown } = scene(t); + assert.equal(state.launchPending(launch), false); + assert.deepEqual(shown(), { marker: true, path: true }); +}); + +test('away from live a launch shows from its lift-off on', (t) => { + const { state, launch, syncs, shown } = scene(t); + worldClock.seek(LAUNCH_MS - 60_000); + assert.equal(state.launchPending(launch), true); + assert.deepEqual(shown(), { marker: false, path: false }); + assert.deepEqual(syncs, { labels: 1, orbits: 1 }, 'its label and orbit go'); + shown(); + assert.deepEqual(syncs, { labels: 1, orbits: 1 }, 'once, not each frame'); + + worldClock.seek(LAUNCH_MS); + assert.equal(state.launchPending(launch), false); + assert.deepEqual(shown(), { marker: true, path: true }); + assert.deepEqual(syncs, { labels: 2, orbits: 2 }); +}); + +test('the selected mission stays whenever it flies', (t) => { + const { state, launch, shown } = scene(t); + state._selectedLaunchId = launch.id; + worldClock.seek(LAUNCH_MS - 60_000); + assert.deepEqual(shown(), { marker: true, path: true }); +}); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index a9be9296d..68fa976fa 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -152,23 +152,27 @@ export function createWorldClock({ anchorMono = monotonicNow(); changed(); }, - /** Back to the wall clock; playback stops. */ + /** Back to the wall clock; playback stops and its rate goes back to 1. */ goLive() { - if (live && !playing) return; + if (live && !playing && rate === 1) return; const stepping = active; live = true; cursorMs = null; playing = false; active = null; + rate = 1; // Its driven(false) finds nothing left to stop. stepping?.driver.pause(); changed(); }, - /** Play from the cursor (from now, when live). */ - play({ rate: nextRate } = {}) { + /** + * Play from the cursor (from now, when live). A driver steps it unless + * `continuous`, which runs the cursor at the rate past every driver. + */ + play({ rate: nextRate, continuous: runOn = false } = {}) { if (nextRate !== undefined) api.setRate(nextRate); if (playing) return; - for (let index = drivers.length - 1; index >= 0; index -= 1) { + for (let index = drivers.length - 1; !runOn && index >= 0; index -= 1) { if (drivers[index].driver.play() !== false) return; } if (live) { diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index 19bc8ba93..0e7a544a3 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -305,3 +305,17 @@ test('a coverage that throws counts as none', (t) => { assert.equal(clock.getState().supported, false); assert.equal(clock.coverage(), null); }); + +test('continuous play runs past every driver, and live resets the rate', () => { + const t = fakeTime(); + const clock = createWorldClock(t.options); + const calls = []; + clock.attachDriver({ play: () => calls.push('play'), pause() {} }); + clock.seek(t.wall - 60_000); + clock.play({ rate: 30, continuous: true }); + assert.deepEqual(calls, []); + t.advance(1_000); + assert.equal(clock.now(), t.wall - 61_000 + 30_000); + clock.goLive(); + assert.equal(clock.getRate(), 1); +}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 62fb4528c..bec2a6a82 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -8,6 +8,8 @@ const LIVE_TICK_MS = 1000; const LIVE_EDGE = 0.005; /** The least that counts as at now, past the readout's own second. */ const LIVE_EDGE_MS = 5000; +/** How long play takes to run the whole track, in real ms. */ +const PLAY_THROUGH_MS = 60_000; /** * The world clock as the timeline shows it: `{live, playing, timeMs, @@ -20,17 +22,55 @@ const LIVE_EDGE_MS = 5000; * track's end keep up with now. */ export const useWorldTimeStore = defineStore('gev-world-time', () => { - const spanOf = (state) => { - if (!state.supported) return null; + // The time the layers cover, reaching to now. + const reach = () => { const coverage = worldClock.coverage(); if (!coverage) return null; const now = worldClock.wallNow(); - return Object.freeze({ - start: Math.min(coverage.start, now, state.timeMs), - end: Math.max(coverage.end, now, state.timeMs), + return { + start: Math.min(coverage.start, now), + end: Math.max(coverage.end, now), marks: coverage.marks, + }; + }; + const spanOf = (state) => { + if (!state.supported) return null; + const covered = reach(); + if (!covered) return null; + return Object.freeze({ + start: Math.min(covered.start, state.timeMs), + end: Math.max(covered.end, state.timeMs), + marks: covered.marks, }); }; + const nearNow = (ms, span) => + Math.abs(ms - worldClock.wallNow()) <= + Math.max(LIVE_EDGE_MS, span ? (span.end - span.start) * LIVE_EDGE : 0); + // Whether the track ends at now (and moves on with it), not past it. + const endsAtNow = (covered) => + covered.end - worldClock.wallNow() <= LIVE_EDGE_MS; + // Play stops at the end of the track: at now it goes live, past now (a + // satellite's future) it holds there. + let stopping = false; + const stopAtEnd = (state) => { + if (stopping || !state.playing || state.live || !state.supported) + return false; + const covered = reach(); + if (!covered || state.timeMs < covered.end) return false; + stopping = true; + try { + if (endsAtNow(covered)) worldClock.goLive(); + else { + // Back to the end first, so nothing hears the overshoot as paused. + worldClock.seek(covered.end); + worldClock.pause(); + worldClock.seek(covered.end); + } + } finally { + stopping = false; + } + return true; + }; const read = (state = worldClock.getState()) => Object.freeze({ live: state.live, @@ -54,6 +94,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { } }; const unsubscribe = worldClock.subscribe((state) => { + if (stopAtEnd(state)) return; time.value = read(state); syncTicker(); }); @@ -66,13 +107,30 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { return { time, - togglePlay: () => worldClock.togglePlay(), + /** + * Play runs the whole track in about a minute, from the start when it is + * at its end (at live, say), as a video plays again from the top. It runs + * the clock itself, past weather's own replay: weather follows the time + * frame by frame like every other layer the track covers. + */ + togglePlay() { + const { playing, live, timeMs, span } = time.value; + if (playing) return worldClock.pause(); + const covered = reach(); + if (!span || !covered) return worldClock.play({ rate: 1 }); + const length = span.end - span.start; + if (live || timeMs >= covered.end - LIVE_EDGE_MS) + worldClock.seek(span.start); + // An end at now moves on with it, so play outruns it by the track. + const rate = endsAtNow(covered) + ? 1 + length / PLAY_THROUGH_MS + : Math.max(1, length / PLAY_THROUGH_MS); + worldClock.play({ rate, continuous: true }); + }, goLive: () => worldClock.goLive(), /** Scrub to `ms`: playback stops where the hand puts it; at now, live. */ scrub(ms) { - const span = time.value.span; - const edge = span ? (span.end - span.start) * LIVE_EDGE : 0; - if (Math.abs(ms - worldClock.wallNow()) <= Math.max(LIVE_EDGE_MS, edge)) { + if (nearNow(ms, time.value.span)) { worldClock.goLive(); return; } diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 60a73540b..1d495d9a2 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -148,3 +148,73 @@ test('the track runs over the time the layers show, to now, and scrubs', async ( assert.equal(worldClock.isLive(), false, 'a scrub past now stays there'); assert.equal(worldClock.now(), ahead); }); + +test('play runs the whole track in a minute, from the start when at live', async (t) => { + const bar = mount(t); + const now = Date.now(); + const start = now - 10 * 86_400_000; + const release = worldClock.hold('test-launches'); + const unregister = worldClock.register({ + id: 'test-launches', + coverage: () => ({ start, end: now - 86_400_000 }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + // Weather's replay stepping its own frames does not take the track's play. + const stepped = []; + const driver = worldClock.attachDriver({ + play: () => stepped.push('play'), + pause: () => stepped.push('pause'), + }); + t.after(() => driver.detach()); + bar().querySelector('.world-timeline-play').click(); + worldClock.pause(); + await nextTick(); + assert.deepEqual(stepped, []); + const rate = worldClock.getRate(); + assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track'); + // The end is now and moves on with it: play outruns it by the track. + const length = Date.now() - start; + assert.ok(Math.abs(rate - (1 + length / 60_000)) < rate * 0.01, 'a minute end to end'); + worldClock.play({ continuous: true }); + + // Playing on to now goes live. + worldClock.seek(Date.now() + 1_000); + await nextTick(); + assert.equal(worldClock.isLive(), true); + assert.equal(worldClock.isPlaying(), false); + assert.equal(worldClock.getRate(), 1, 'live plays at the rate of the world'); +}); + +test('play holds at the end of a track that reaches past now', async (t) => { + const bar = mount(t); + const now = Date.now(); + // An hour ahead is within the track's share of now, but still ahead of it. + const end = now + 3_600_000; + const release = worldClock.hold('test-ahead'); + const unregister = worldClock.register({ + id: 'test-ahead', + coverage: () => ({ start: now - 10 * 86_400_000, end }), + }); + t.after(() => { + unregister(); + release(); + }); + await nextTick(); + // Paused just past now, play plays on rather than from the top. + worldClock.seek(Date.now() + 1_000); + await nextTick(); + const from = worldClock.now(); + bar().querySelector('.world-timeline-play').click(); + worldClock.pause(); + assert.ok(worldClock.now() >= from, 'plays on from where it was'); + worldClock.play(); + worldClock.seek(end + 60_000); + await nextTick(); + assert.equal(worldClock.isPlaying(), false); + assert.equal(worldClock.isLive(), false); + assert.equal(worldClock.now(), end); +});