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.
This commit is contained in:
Sameh Khamis committed 2026-11-02 07:21:01 -08:00
1 parent 48093108b1
commit 0aa54dfa97
9 files changed
+326 -25

No files matched your search

+3
View File
@@ -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),
);
+4 -1
View File
@@ -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,
+37 -10
View File
@@ -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.
+20
View File
@@ -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();
+102
View File
@@ -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 });
});
+9 -5
View File
@@ -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) {
+14
View File
@@ -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);
});
+67 -9
View File
@@ -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;
}
@@ -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);
});