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); +});