diff --git a/changelog.d/1512-world-clock-timeline.md b/changelog.d/1512-world-clock-timeline.md
new file mode 100644
index 000000000..a43231150
--- /dev/null
+++ b/changelog.d/1512-world-clock-timeline.md
@@ -0,0 +1,5 @@
+- One world clock now drives everything with a time of its own: the sky,
+ the HUD clock, satellites, launches and weather. A timeline along the
+ bottom of the screen, shown while one of those layers is on, plays, pauses
+ and scrubs it, with a tick for each launch or frame and a way back to
+ live. Share links made away from live carry the time (samehkhamis, #1512).
diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md
index 5728d1ec3..bf69653f9 100644
--- a/docs/SHARE-LINK-FORMAT.md
+++ b/docs/SHARE-LINK-FORMAT.md
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
+| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code.
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
layout and restores it as `tactical`. The code stays reserved.
+Field 28 is the world time the link was made at, written only when it was away
+from live. A link without it opens live. Unlike field 8 it may lie ahead of the
+link's creation (a forecast moment), and it opens paused at that time.
+
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
diff --git a/index.html b/index.html
index f5e3adc83..cc773b4d6 100644
--- a/index.html
+++ b/index.html
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
-
+
diff --git a/package.json b/package.json
index ffc554d7b..ca9112cd5 100644
--- a/package.json
+++ b/package.json
@@ -90,6 +90,7 @@
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js",
+ "./time": "./src/time/worldClock.js",
"./build/vite": {
"node": "./build/vite.js"
},
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index e224cd87e..517265ef7 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -155,10 +155,19 @@
"src/ui/stores/recording.js",
"src/ui/styleParameters.js",
"src/ui/i18n.js",
+ "src/time/worldClock.js",
+ "src/ui/kit/UiIcon.vue",
+ "src/ui/stores/worldTime.js",
+ "src/ui/views/panels/WorldTimeline.vue",
"src/ui/surfaceKeyboard.js"
],
"external": ["vue", "pinia", "vue-i18n"]
},
+ "time": {
+ "exports": ["./time"],
+ "modules": ["src/time/worldClock.js"],
+ "external": []
+ },
"viewer": {
"exports": ["./application/viewer"],
"modules": [
@@ -1179,6 +1188,7 @@
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js",
+ "src/time/worldClock.js",
"src/ui/kit/UiSegmented.vue",
"src/ui/wildlifeSightingCard.js",
"src/layers/wildlifeSightings/records.js",
@@ -1399,7 +1409,9 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
- "src/ui/unitLabels.js"
+ "src/ui/unitLabels.js",
+ "src/time/worldClock.js",
+ "src/time/julian.js"
],
"external": ["cesium"]
},
@@ -1456,7 +1468,9 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
- "src/i18n/locales/zh-Hant.json"
+ "src/i18n/locales/zh-Hant.json",
+ "src/time/worldClock.js",
+ "src/time/julian.js"
],
"external": ["cesium"]
},
@@ -1645,7 +1659,8 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
- "src/ui/unitLabels.js"
+ "src/ui/unitLabels.js",
+ "src/time/worldClock.js"
],
"external": ["cesium", "satellite.js"]
},
@@ -1685,7 +1700,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
- "src/i18n/locales/zh-Hant.json"
+ "src/i18n/locales/zh-Hant.json",
+ "src/time/worldClock.js"
],
"external": ["cesium"]
},
@@ -2010,7 +2026,8 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
- "src/layers/streetLevel/providers/mapillary/policy.js"
+ "src/layers/streetLevel/providers/mapillary/policy.js",
+ "src/time/worldClock.js"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
},
@@ -3284,6 +3301,9 @@
"src/ui/stores/globeActions.js",
"src/ui/stores/recording.js",
"src/ui/i18n.js",
+ "src/time/viewerClock.js",
+ "src/time/worldClock.js",
+ "src/time/julian.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -3946,6 +3966,8 @@
"src/ui/stores/bhoteKoshiPanel.js",
"src/hudReadouts.js",
"src/ui/i18n.js",
+ "src/time/worldClock.js",
+ "src/time/julian.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -4132,7 +4154,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
- "src/i18n/locales/zh-Hant.json"
+ "src/i18n/locales/zh-Hant.json",
+ "src/time/worldClock.js"
],
"external": []
},
@@ -4675,6 +4698,7 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
+ "src/time/worldClock.js",
"src/overlays/worldOverlayChrome.js"
],
"external": ["cesium", "egm96-universal", "pbf"]
@@ -4728,7 +4752,8 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
- "src/i18n/locales/zh-Hant.json"
+ "src/i18n/locales/zh-Hant.json",
+ "src/time/worldClock.js"
],
"external": ["cesium", "pbf"]
},
@@ -4850,7 +4875,8 @@
"src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js",
"src/i18n/marker.js",
- "src/visualStylePolicy.js"
+ "src/visualStylePolicy.js",
+ "src/time/worldClock.js"
],
"external": []
},
diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js
index f368d260e..a76b37bd4 100644
--- a/src/app/constructCatalog.js
+++ b/src/app/constructCatalog.js
@@ -1,5 +1,6 @@
import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js';
+import { worldClock } from '../time/worldClock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createApplicationCyclones } from './layers/cyclones.js';
import { createWindLayer } from '../layers/wind/index.js';
@@ -78,7 +79,7 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry();
- const weatherClock = createWeatherClock();
+ const weatherClock = createWeatherClock({ world: worldClock });
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js
index 46983953b..72071e86a 100644
--- a/src/app/layers/gnss.js
+++ b/src/app/layers/gnss.js
@@ -1,6 +1,10 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
+import { worldTimeFor } from '../../time/worldClock.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
- return createGnssIntegrityLayer(options);
+ return createGnssIntegrityLayer({
+ worldNow: worldTimeFor('gnss-integrity'),
+ ...options,
+ });
}
diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js
index cc4659727..6a730bc7c 100644
--- a/src/app/layers/recentImagery.js
+++ b/src/app/layers/recentImagery.js
@@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
+import { worldTimeFor } from '../../time/worldClock.js';
+
+const imageryTime = worldTimeFor('recent-imagery');
/** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) {
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}),
+ now: () => new Date(imageryTime()),
});
}
diff --git a/src/app/scene.js b/src/app/scene.js
index 8792a1f4d..b9799b8ac 100644
--- a/src/app/scene.js
+++ b/src/app/scene.js
@@ -15,6 +15,8 @@ import {
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
+import { worldClock, worldTimeFor } from '../time/worldClock.js';
+import { followWorldTime } from '../time/viewerClock.js';
import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */
@@ -59,6 +61,12 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
+ defer(
+ followWorldTime(viewer, worldTimeFor('sky'), {
+ subscribe: worldClock.subscribe,
+ requestRender: () => governorRequestRender('world-time'),
+ }),
+ );
defer(installFreeLookHeightLimit(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
diff --git a/src/celestialRing.js b/src/celestialRing.js
index c031593d4..f470e7d18 100644
--- a/src/celestialRing.js
+++ b/src/celestialRing.js
@@ -1,5 +1,9 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js';
+import { worldClock, worldTimeFor } from './time/worldClock.js';
+
+// The ring shows the sun and moon at the time the sky is drawn.
+const skyTime = worldTimeFor('sky');
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05;
@@ -445,6 +449,12 @@ export class CelestialRing {
if (typeof document !== 'undefined' && document.hidden) return;
governorRequestRender('celestial-ephemeris');
}, 60_000);
+ // A jump in the world time moves the sun and moon at once.
+ this._skyDate = new Date();
+ this._skyTime = new Cesium.JulianDate();
+ this._unsubscribeWorldTime = worldClock.subscribe(() => {
+ this._ephemerisDirty = true;
+ });
this.setEnabled(this.enabled);
}
@@ -801,7 +811,8 @@ export class CelestialRing {
return;
}
- const time = Cesium.JulianDate.now();
+ this._skyDate.setTime(skyTime());
+ const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
if (!this._updateEphemeris(time)) return;
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera;
@@ -890,6 +901,8 @@ export class CelestialRing {
clearInterval(this._ephemerisTimer);
this._ephemerisTimer = null;
}
+ this._unsubscribeWorldTime?.();
+ this._unsubscribeWorldTime = null;
if (this._removePostRender) this._removePostRender();
this._removePostRender = null;
if (this._shown()) {
diff --git a/src/creditAttribution.test.mjs b/src/creditAttribution.test.mjs
index 3032a25fe..39fd5b8f2 100644
--- a/src/creditAttribution.test.mjs
+++ b/src/creditAttribution.test.mjs
@@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
// stack height, proven non-negative below, so evaluating them at 0 is a
-// conservative floor rather than a guess.
+// conservative floor rather than a guess. The world timeline's lift raises
+// the dock, its trays and the credit together, so it moves no gap either.
const VETTED_VARS = new Set([
'--dock-pinned-stack-height',
'--dock-lower-pinned-height',
'--dock-location-pinned-height',
'--dock-presets-pinned-height',
+ '--world-timeline-lift',
]);
+// Custom properties CSS sets to a fixed length besides their 0 default. The
+// clearance checks run at each length CSS gives them, not only at 0.
+const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
+const LIFTS_PX = [0, 50, 80];
+let liftPx = 0;
+function eachLift(check) {
+ for (const lift of LIFTS_PX) {
+ liftPx = lift;
+ try {
+ check(lift);
+ } finally {
+ liftPx = 0;
+ }
+ }
+}
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
function ownBoxEntries() {
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
if (term.startsWith('var(')) {
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
+ if (name === '--world-timeline-lift') total += liftPx;
continue; // proven non-negative; 0 is the conservative floor
}
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
@@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () =
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
for (const decl of declared) {
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
- assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
+ const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
+ assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
}
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const start = layout.indexOf('_updateCommandDockTrayStack() {');
@@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
// 8px at 720px, while the credit keeps its 2vh base throughout.
const failures = [];
- for (const scenario of TRAY_SCENARIOS) {
- for (const width of WIDTHS.filter((w) => w <= 900)) {
- for (const height of HEIGHTS) {
- const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
- if (clearance < MIN_CLEARANCE_PX) {
- failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`);
+ eachLift((lift) => {
+ for (const scenario of TRAY_SCENARIOS) {
+ for (const width of WIDTHS.filter((w) => w <= 900)) {
+ for (const height of HEIGHTS) {
+ const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
+ if (clearance < MIN_CLEARANCE_PX) {
+ failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
+ }
}
}
}
- }
+ });
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
});
@@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v
'none',
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
);
- for (const height of HEIGHTS) {
- const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
- const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
- if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`);
- }
+ eachLift((lift) => {
+ for (const height of HEIGHTS) {
+ const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
+ const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
+ if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
+ }
+ });
}
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
});
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
- assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
- assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
+ const lift = 'var(--world-timeline-lift, 0px)';
+ assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
+ assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
assert.equal(
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
- 'calc(2vh + 5rem)',
+ `calc(2vh + 5rem + ${lift})`,
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
);
});
diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs
index 38ff4d24c..f1c66f032 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,
@@ -1240,7 +1241,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');
@@ -1379,6 +1384,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],
@@ -1500,3 +1506,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/hud.js b/src/hud.js
index 7e61fe91b..37e9dfcb4 100644
--- a/src/hud.js
+++ b/src/hud.js
@@ -23,6 +23,11 @@ import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js';
+import { worldTimeFor } from './time/worldClock.js';
+
+// The HUD stamps its readouts with the world time; its sun is the sky's.
+const hudTime = worldTimeFor('hud');
+const skyTime = worldTimeFor('sky');
import { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import {
@@ -447,11 +452,11 @@ export class IntelHUD {
}
/**
- * Format the current wall-clock time as a UTC Zulu string.
+ * Format the HUD's world time as a UTC Zulu string.
* @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format.
*/
_formatUTC() {
- const now = new Date();
+ const now = new Date(hudTime());
const y = now.getUTCFullYear();
const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0');
@@ -554,7 +559,7 @@ export class IntelHUD {
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp
- const now = new Date();
+ const now = new Date(hudTime());
const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0');
@@ -661,7 +666,7 @@ export class IntelHUD {
* @returns {number} Estimated sun elevation in degrees (negative = below horizon).
*/
_estimateSunElevation(lat, lon) {
- const now = new Date();
+ const now = new Date(skyTime());
// Approximate local solar time by shifting UTC hours by longitude offset
const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15;
const solarNoon = 12;
diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json
index 189a025d5..995c75e83 100644
--- a/src/i18n/locales/ar.json
+++ b/src/i18n/locales/ar.json
@@ -3484,5 +3484,8 @@
"Close camera preview": "إغلاق معاينة الكاميرا",
"Refresh map still": "تحديث صورة الخريطة الثابتة",
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
- "Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك."
+ "Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.",
+ "World time": "وقت العالم",
+ "Back to live": "العودة إلى البث المباشر",
+ "Live": "مباشر"
}
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 419afc9a6..8c02f91e5 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -3184,5 +3184,8 @@
"Close camera preview": "Kameravorschau schließen",
"Refresh map still": "Kartenstandbild aktualisieren",
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
- "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht."
+ "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.",
+ "World time": "Weltzeit",
+ "Back to live": "Zurück zu live",
+ "Live": "Live"
}
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index 48963ac30..1f1c15981 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -3259,5 +3259,8 @@
"Close camera preview": "Cerrar vista previa de la cámara",
"Refresh map still": "Actualizar imagen fija del mapa",
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
- "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP."
+ "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.",
+ "World time": "Hora mundial",
+ "Back to live": "Volver al directo",
+ "Live": "En directo"
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 16fc249b6..e8daee830 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -3259,5 +3259,8 @@
"Close camera preview": "Fermer l’aperçu de la caméra",
"Refresh map still": "Actualiser l’image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
- "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP."
+ "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
+ "World time": "Heure du monde",
+ "Back to live": "Revenir au direct",
+ "Live": "Direct"
}
diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json
index 7f130832f..5dc1ed6b4 100644
--- a/src/i18n/locales/it.json
+++ b/src/i18n/locales/it.json
@@ -3259,5 +3259,8 @@
"Close camera preview": "Chiudi anteprima della telecamera",
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
- "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP."
+ "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.",
+ "World time": "Ora del mondo",
+ "Back to live": "Torna alla diretta",
+ "Live": "Diretta"
}
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 62e8af101..a116806a9 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -3109,5 +3109,8 @@
"Close camera preview": "カメラのプレビューを閉じる",
"Refresh map still": "地図上の静止画を更新",
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
- "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。"
+ "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。",
+ "World time": "世界時刻",
+ "Back to live": "ライブに戻る",
+ "Live": "ライブ"
}
diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json
index 04cf3ccfb..65f052f70 100644
--- a/src/i18n/locales/pt.json
+++ b/src/i18n/locales/pt.json
@@ -3259,5 +3259,8 @@
"Close camera preview": "Fechar prévia da câmera",
"Refresh map still": "Atualizar imagem fixa no mapa",
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
- "Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP."
+ "Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.",
+ "World time": "Hora do mundo",
+ "Back to live": "Voltar ao vivo",
+ "Live": "Ao vivo"
}
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
index eb0080bce..cd3bfbee3 100644
--- a/src/i18n/locales/ru.json
+++ b/src/i18n/locales/ru.json
@@ -3334,5 +3334,8 @@
"Close camera preview": "Закрыть просмотр камеры",
"Refresh map still": "Обновить кадр на карте",
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
- "Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес."
+ "Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.",
+ "World time": "Мировое время",
+ "Back to live": "Вернуться к прямому эфиру",
+ "Live": "Сейчас"
}
diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json
index a61335f2d..9677fd677 100644
--- a/src/i18n/locales/uk.json
+++ b/src/i18n/locales/uk.json
@@ -3334,5 +3334,8 @@
"Close camera preview": "Закрити перегляд камери",
"Refresh map still": "Оновити кадр на карті",
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
- "Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу."
+ "Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.",
+ "World time": "Світовий час",
+ "Back to live": "Повернутися до прямого ефіру",
+ "Live": "Зараз"
}
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
index 89236f024..efdb166c2 100644
--- a/src/i18n/locales/zh-CN.json
+++ b/src/i18n/locales/zh-CN.json
@@ -3109,5 +3109,8 @@
"Close camera preview": "关闭摄像头预览",
"Refresh map still": "刷新地图静止画面",
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
- "Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。"
+ "Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。",
+ "World time": "世界时间",
+ "Back to live": "返回实时",
+ "Live": "实时"
}
diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json
index e3ce492df..6b4c24211 100644
--- a/src/i18n/locales/zh-Hant.json
+++ b/src/i18n/locales/zh-Hant.json
@@ -3109,5 +3109,8 @@
"Close camera preview": "關閉攝影機預覽",
"Refresh map still": "重新整理地圖靜態畫面",
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
- "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。"
+ "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。",
+ "World time": "世界時間",
+ "Back to live": "返回即時",
+ "Live": "即時"
}
diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json
index 0dc9841f7..97e081b18 100644
--- a/src/i18n/same-as-english.json
+++ b/src/i18n/same-as-english.json
@@ -98,6 +98,7 @@
"Landsat 8/9",
"Latin",
"Layout",
+ "Live",
"London",
"Los Angeles",
"MIL",
diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js
index b66775f1e..a14c8c4ac 100644
--- a/src/layers/flights/motion.js
+++ b/src/layers/flights/motion.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { julianAt } from '../../time/julian.js';
import {
COURSE_HOLD_SPEED_MPS,
lerpAngleDeg,
@@ -156,7 +157,7 @@ export function createMotion({
const renderTime =
sampledRenderTime ??
Cesium.JulianDate.addSeconds(
- Cesium.JulianDate.now(flightState._scratchRenderTime),
+ julianAt(flightState.now(), flightState._scratchRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
@@ -332,7 +333,7 @@ export function createMotion({
const history = flightState._positionHistory.get(flightState._trackedIcao);
if (!history || history.length === 0) return true;
const renderTime = Cesium.JulianDate.addSeconds(
- Cesium.JulianDate.now(),
+ julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
diff --git a/src/layers/flights/motion.test.mjs b/src/layers/flights/motion.test.mjs
index dcb809d86..dd7a0ab03 100644
--- a/src/layers/flights/motion.test.mjs
+++ b/src/layers/flights/motion.test.mjs
@@ -12,6 +12,7 @@ function fixture() {
_scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(),
+ now: Date.now,
};
const motion = createMotion({
flightState,
@@ -138,7 +139,7 @@ test('fleet render time is shared without resampling or mutating the supplied in
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
});
-test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
+test('tracked callers still sample a fresh delayed time without a fleet epoch', () => {
const { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const history = [
@@ -156,10 +157,11 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
flightState._positionHistory.set('tracked', history);
let elapsed = 60;
- const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
- assert.equal(result, flightState._scratchRenderTime);
- return Cesium.JulianDate.addSeconds(start, elapsed, result);
- });
+ let reads = 0;
+ flightState.now = () => {
+ reads += 1;
+ return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000;
+ };
const out = new Cesium.Cartesian3();
close(
motion._deadReckon('tracked', out),
@@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
new Cesium.Cartesian3(),
),
);
- assert.equal(spy.mock.callCount(), 2);
+ assert.equal(reads, 2);
});
diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js
index 6709f9647..333935bc1 100644
--- a/src/layers/flights/rendering.js
+++ b/src/layers/flights/rendering.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { julianAt } from '../../time/julian.js';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js';
@@ -871,7 +872,7 @@ export function createRendering({
flightState._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds(
- Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
+ julianAt(flightState.now(), flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC,
flightState._scratchFleetRenderTime,
);
diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js
index 25d1c1aa3..c259c44f1 100644
--- a/src/layers/flights/state.js
+++ b/src/layers/flights/state.js
@@ -1,6 +1,7 @@
import { FlightRecords } from './records.js';
import { createFlightFeed } from './ingestion.js';
import * as Cesium from 'cesium';
+import { worldTimeFor } from '../../time/worldClock.js';
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
export function createFlightState({ source, services }) {
@@ -12,6 +13,8 @@ export function createFlightState({ source, services }) {
...services.groundFloor,
});
flightState._cullPositions = new Map();
+ /** The time contacts are drawn behind by the render delay, in epoch ms. */
+ flightState.now = worldTimeFor('flights');
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js
index 59270892e..f81249e88 100644
--- a/src/layers/flights/tracking.js
+++ b/src/layers/flights/tracking.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { julianAt } from '../../time/julian.js';
import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
@@ -383,7 +384,7 @@ export function createTracking({
// would draw the trail in front of the plane. They join the trail via _appendTrailFix
// as they age past the delay.
const seedRenderTime = Cesium.JulianDate.addSeconds(
- Cesium.JulianDate.now(),
+ julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js
index ba0e9ff5d..7a9087998 100644
--- a/src/layers/gnss/index.js
+++ b/src/layers/gnss/index.js
@@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({
source,
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
+ // The end of the window shown; retries and receipt times stay on `now`.
+ worldNow = now,
monotonicNow = () => performance.now(),
windowMs: requestedWindowMs,
} = {}) {
@@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({
/** Age the store without new evidence, so expired cells leave the globe. */
function expire() {
- pruneGnssObservations(_observations, now(), { windowMs });
+ pruneGnssObservations(_observations, worldNow(), { windowMs });
_cells = binGnssCells(_observations);
render();
}
diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js
index 9adc88922..93f667951 100644
--- a/src/layers/launches/controls.js
+++ b/src/layers/launches/controls.js
@@ -29,7 +29,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 fbd3778e1..3bf6b9014 100644
--- a/src/layers/launches/ingestion.js
+++ b/src/layers/launches/ingestion.js
@@ -224,6 +224,7 @@ export function createIngestion({
}
layerState._focusAfterActiveLookup = false;
parts.panel.renderMissionPanel();
+ layerState.coverageChanged();
layerState._entryCameraView = null;
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
diff --git a/src/layers/launches/launchPad.js b/src/layers/launches/launchPad.js
index f0a87ce6a..9fd438762 100644
--- a/src/layers/launches/launchPad.js
+++ b/src/layers/launches/launchPad.js
@@ -105,7 +105,7 @@ export function createLaunchPad({
}
const nowMs = Date.now();
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
- layerState._missionRingDate.setTime(nowMs);
+ layerState._missionRingDate.setTime(layerState.orbitNow());
parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
layerState._missionRingDate,
);
diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js
index 252ca7ab1..2bff72090 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;
@@ -156,6 +157,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) {
@@ -231,6 +247,7 @@ export function createLifecycle({
}
throw error;
}
+ layerState.coverageChanged();
},
async _enableBody() {
@@ -297,6 +314,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/orbitRendering.js b/src/layers/launches/orbitRendering.js
index d907904eb..b9a6cab09 100644
--- a/src/layers/launches/orbitRendering.js
+++ b/src/layers/launches/orbitRendering.js
@@ -124,7 +124,7 @@ export function createOrbitRendering({
collection.show = missionOrbitPrimitiveVisible(launch.id);
orbitFrameModelMatrix(
satelliteTrack.gmstAtBake,
- new Date(),
+ new Date(layerState.orbitNow()),
collection.modelMatrix,
);
layerState._viewer.scene.primitives.add(collection);
diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js
index 6379e78e1..52eb39961 100644
--- a/src/layers/launches/rendering.js
+++ b/src/layers/launches/rendering.js
@@ -394,7 +394,7 @@ export function createRendering({
orbitCurrent.latitude,
orbitCurrent.altitude,
);
- const liveTime = new Date();
+ const liveTime = new Date(layerState.orbitNow());
const liveTelemetry = { speedMps: null };
layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
let liveFrameNumber = -1;
@@ -405,7 +405,7 @@ export function createRendering({
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
liveFrameNumber = frameNumber;
- const nowMs = Date.now();
+ const nowMs = layerState.orbitNow();
liveTime.setTime(nowMs);
if (satelliteTrack) {
const propagated =
diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js
index fd2ae7176..17026dcfd 100644
--- a/src/layers/launches/state.js
+++ b/src/layers/launches/state.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { worldClock, worldTimeFor } from '../../time/worldClock.js';
export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
@@ -156,6 +157,18 @@ export function createState({ services }) {
state._missionRingDate = new Date(0);
+ /** The time the launch window ends at, in epoch ms. */
+
+ state.now = worldTimeFor('rocket-launches');
+
+ /** A launched mission's satellite flies on the satellites layer's time. */
+
+ 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/military/motion.js b/src/layers/military/motion.js
index 27489400b..a13508e54 100644
--- a/src/layers/military/motion.js
+++ b/src/layers/military/motion.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { julianAt } from '../../time/julian.js';
import {
lerpAngleDeg,
speedRamp,
@@ -67,7 +68,7 @@ export function createMotion({
// Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = Cesium.JulianDate.addSeconds(
- Cesium.JulianDate.now(),
+ julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchRenderTime,
);
@@ -213,7 +214,7 @@ export function createMotion({
const history = flightState._positionHistory.get(flightState._trackedIcao);
if (!history || history.length === 0) return true;
const renderTime = Cesium.JulianDate.addSeconds(
- Cesium.JulianDate.now(),
+ julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
diff --git a/src/layers/military/state.js b/src/layers/military/state.js
index f1a2d691f..f27595d2b 100644
--- a/src/layers/military/state.js
+++ b/src/layers/military/state.js
@@ -1,6 +1,7 @@
import { MilitaryFlightRecords } from './records.js';
import { createMilitaryFeed } from './ingestion.js';
import * as Cesium from 'cesium';
+import { worldTimeFor } from '../../time/worldClock.js';
export function createFlightState({ source, services }) {
const { createGroundSnap } = services.groundSnap;
@@ -11,6 +12,8 @@ export function createFlightState({ source, services }) {
...services.groundFloor,
});
flightState._cullPositions = new Map();
+ /** The time contacts are drawn behind by the render delay, in epoch ms. */
+ flightState.now = worldTimeFor('military');
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
* military contacts should read as their WEIGHT CLASS, always in this layer's
diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js
index 5ad60adf9..ed40ff4ac 100644
--- a/src/layers/military/tracking.js
+++ b/src/layers/military/tracking.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { julianAt } from '../../time/julian.js';
import { t } from '../../i18n/index.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
@@ -500,7 +501,7 @@ export function createTracking({
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
// would draw the trail in front of the plane. They join via _appendTrailFix as they age.
const seedRenderTime = Cesium.JulianDate.addSeconds(
- Cesium.JulianDate.now(),
+ julianAt(flightState.now()),
-RENDER_DELAY_SEC,
flightState._scratchWarmupTime,
);
diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js
index f4a347af2..ed38e5dbb 100644
--- a/src/layers/satellites/catalog.js
+++ b/src/layers/satellites/catalog.js
@@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
1,
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
);
- const now = new Date();
+ const now = new Date(layerState.now());
for (let i = 0; i < perFrame; i++) {
if (layerState._denseCursor >= layerState._denseIds.length)
layerState._denseCursor = 0;
@@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
const entries = parts.orbits.parseTLE(text);
const style = POINT_STYLES.dense;
- const now = new Date();
+ const now = new Date(layerState.now());
let added = 0;
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
@@ -165,6 +165,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
layerState._count = layerState._points.size;
layerState._catalogRevision++;
+ layerState.coverageChanged();
layerState._denseStatus = 'ready';
console.log(
`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`,
@@ -232,6 +233,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
layerState._denseCursor = 0;
layerState._count = layerState._points.size;
layerState._catalogRevision++;
+ layerState.coverageChanged();
}
return {
_abortActiveUpdates,
diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js
index f3327b332..bb33a0c40 100644
--- a/src/layers/satellites/controls.js
+++ b/src/layers/satellites/controls.js
@@ -190,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) {
if (noradId === null) return null;
const sat = layerState._catalog.get(noradId);
- const pos = parts.orbits.propagatePosition(sat.satrec, new Date());
+ const pos = parts.orbits.propagatePosition(
+ sat.satrec,
+ new Date(layerState.now()),
+ );
if (!pos) return null;
return {
diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js
index d2cc16e15..75c0b3c7e 100644
--- a/src/layers/satellites/ingestion.js
+++ b/src/layers/satellites/ingestion.js
@@ -103,7 +103,7 @@ export function createIngestion({
layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog
parts.labels._syncIssOverlay();
- const now = new Date();
+ const now = new Date(layerState.now());
// Process all TLE entries in CATALOG_GROUPS order
const allEntries = [];
@@ -195,6 +195,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/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs
index beea1a518..6009b3195 100644
--- a/src/layers/satellites/ingestion.test.mjs
+++ b/src/layers/satellites/ingestion.test.mjs
@@ -33,6 +33,8 @@ function ingestionFor(catalogText) {
_denseLoadToken: 0,
_catalogRevision: 0,
_params: { catalog: 'core', showOrbits: false },
+ now: Date.now,
+ coverageChanged() {},
};
const parts = {
orbits: {
diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js
index 642a6879f..27af96f29 100644
--- a/src/layers/satellites/lifecycle.js
+++ b/src/layers/satellites/lifecycle.js
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
-import { ISS_OVERLAY_SOURCE_ID } from './policy.js';
+import { ISS_OVERLAY_SOURCE_ID, SATELLITE_COVERAGE_MS } from './policy.js';
+import { worldClock } from '../../time/worldClock.js';
export function createLifecycle({
state: layerState,
@@ -8,6 +9,20 @@ export function createLifecycle({
source,
}) {
const { clearFocusTarget } = services.focus;
+ // While on with a catalog, satellites can be shown at any time in reach of
+ // 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;
@@ -46,6 +61,7 @@ export function createLifecycle({
layerState._denseStatus = 'idle';
layerState._denseError = null;
layerState._catalogRevision++;
+ layerState.coverageChanged();
// Point primitives for satellite dots
layerState._pointCollection = new Cesium.PointPrimitiveCollection();
@@ -92,6 +108,7 @@ export function createLifecycle({
);
}
parts.tracking._applyPendingTrackingRestore();
+ layerState.coverageChanged();
},
disable(viewer) {
@@ -119,6 +136,7 @@ export function createLifecycle({
layerState._preRenderListener();
layerState._preRenderListener = null;
}
+ layerState.coverageChanged();
},
destroy(viewer) {
@@ -176,6 +194,7 @@ export function createLifecycle({
failedGroups: [],
};
layerState._viewer = null;
+ layerState.coverageChanged();
},
};
diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js
index a2bfbd939..c2d2e67e8 100644
--- a/src/layers/satellites/orbits.js
+++ b/src/layers/satellites/orbits.js
@@ -137,7 +137,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
lonDeg,
minElevDeg = 10,
requireVisible = false,
- fromMs = Date.now(),
+ fromMs = layerState.now(),
}) {
return getNextSatellitePass(ISS_NORAD, {
latDeg,
@@ -167,7 +167,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
lonDeg,
minElevDeg = 10,
requireVisible = false,
- fromMs = Date.now(),
+ fromMs = layerState.now(),
},
) {
const id = Number(noradId);
@@ -271,7 +271,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
}
function orbitTrackFromRecord(name, satrec) {
- const referenceDate = new Date();
+ const referenceDate = new Date(layerState.now());
const current = propagatePosition(satrec, referenceDate);
if (!current) return null;
return {
diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js
index e7ea5456d..a7eecbbf2 100644
--- a/src/layers/satellites/policy.js
+++ b/src/layers/satellites/policy.js
@@ -28,6 +28,15 @@ export const ORBIT_PATH_STEPS = 180;
// points per orbital path
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/records.js b/src/layers/satellites/records.js
index d4fb4d076..9f99d6b0d 100644
--- a/src/layers/satellites/records.js
+++ b/src/layers/satellites/records.js
@@ -79,7 +79,7 @@ export function createRecords({ state, parts }) {
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
- const now = new Date();
+ const now = new Date(state.now());
const result = [];
const emit = (noradId, sat) => {
if (result.length >= limit) return false;
diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js
index 08e4396bc..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,
@@ -42,7 +47,7 @@ export function createRendering({
const sat = layerState._catalog.get(noradId);
if (!sat || !layerState._viewer) return;
- const bakeDate = new Date();
+ const bakeDate = new Date(layerState.now());
const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate);
if (basePositions.length < 2) return;
@@ -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,
@@ -143,7 +157,7 @@ export function createRendering({
*/
function _propagateAll() {
- const now = new Date();
+ const now = new Date(layerState.now());
let updated = 0;
for (const [noradId, sat] of layerState._catalog) {
@@ -211,7 +225,7 @@ export function createRendering({
layerState._params.showOrbits &&
now - layerState._lastRingRotation >= RING_ROTATION_MS
) {
- _updateOrbitPathRotations(new Date(now));
+ _updateOrbitPathRotations(new Date(layerState.now()));
layerState._lastRingRotation = now;
}
}
diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js
index 50a127b54..8464fb275 100644
--- a/src/layers/satellites/state.js
+++ b/src/layers/satellites/state.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { worldClock, worldTimeFor } from '../../time/worldClock.js';
export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
@@ -151,6 +152,14 @@ export function createState({ services }) {
state._trackedFrameNowForTest = null;
+ /** The time satellites are propagated to, in epoch ms. */
+
+ state.now = worldTimeFor('satellites');
+
+ /** The catalog changed, so the time satellites can be shown at may have. */
+
+ state.coverageChanged = () => worldClock.coverageChanged();
+
// Scratch variables
state._scratchCartesian = new Cesium.Cartesian3();
diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js
index 2b2624ff7..7353d92c8 100644
--- a/src/layers/satellites/tracking.js
+++ b/src/layers/satellites/tracking.js
@@ -154,7 +154,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
sat.satrec,
layerState._trackedFrameNowForTest
? new Date(layerState._trackedFrameNowForTest())
- : new Date(),
+ : new Date(layerState.now()),
);
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
layerState._trackedFrameGeo = pos;
@@ -434,7 +434,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
// Comfortable tracking landing: ~726 km back for LEO (user-validated
// "slightly zoomed out" framing — no stutter, label reads cleanly), scaled
// up for MEO/GEO so the camera doesn't land on top of a high-orbit dot.
- const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date());
+ const initialPos = parts.orbits.propagatePosition(
+ sat.satrec,
+ new Date(layerState.now()),
+ );
const viewScale =
initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M
? TRACK_VIEW_FROM_HIGH_SCALE
diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js
index 9c22b95a5..f3b421991 100644
--- a/src/layers/streetLevel/index.js
+++ b/src/layers/streetLevel/index.js
@@ -8,6 +8,7 @@ import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
+import { worldTimeFor } from '../../time/worldClock.js';
import { decodeParams, encodeParams } from './params.js';
import {
composeUIState,
@@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000;
* a time.
* @param {{providers: Array, services?: object}} options
*/
+// "Since N days" counts back from the world time.
+const streetLevelTime = worldTimeFor(STREET_LEVEL_LAYER_ID);
+
export function createStreetLevelLayer({
providers: definitions,
services = {},
@@ -93,7 +97,7 @@ export function createStreetLevelLayer({
return Object.freeze({
services: state.services,
/** Resolved against now, so "since N days" keeps up with the clock. */
- getFilter: () => resolveFilter(state.filter),
+ getFilter: () => resolveFilter(state.filter, streetLevelTime()),
/** The layer and this provider's switch are both on. */
isActive: () => state.enabled && entry.on,
notify,
@@ -365,7 +369,7 @@ export function createStreetLevelLayer({
const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return;
state.filter = filter;
- const resolved = resolveFilter(filter);
+ const resolved = resolveFilter(filter, streetLevelTime());
for (const entry of state.providers.values())
entry.instance.setFilter(resolved);
notify();
diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs
index ea547f85d..d1c14dd64 100644
--- a/src/layers/transit/lifecycle.test.mjs
+++ b/src/layers/transit/lifecycle.test.mjs
@@ -3927,6 +3927,7 @@ test('parked transit reprojects held and reported courses at the bounded camera
_rotationRevision: -1,
_cameraRevision: 0,
_rotationDirty: false,
+ now: () => Date.now(),
};
const noop = () => {};
const rendering = createRendering({
diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js
index 2309978a5..f3868444a 100644
--- a/src/layers/transit/rendering.js
+++ b/src/layers/transit/rendering.js
@@ -108,7 +108,7 @@ export function createRendering({ state, services, parts }) {
}
function sampleIdle(entry) {
if (!state._moving.has(entry)) {
- updatePlayback(entry, Date.now(), performance.now());
+ updatePlayback(entry, state.now(), performance.now());
placeSample(entry);
}
}
@@ -172,7 +172,7 @@ export function createRendering({ state, services, parts }) {
() => {
entry.wakeTimer = null;
if (!state._enabled || !state._vehicles.has(entry.key)) return;
- updatePlayback(entry, Date.now(), performance.now());
+ updatePlayback(entry, state.now(), performance.now());
placeSample(entry);
schedulePlayback(entry);
syncRenderHold();
@@ -388,7 +388,7 @@ export function createRendering({ state, services, parts }) {
)
return;
if (!state._enabled) return;
- const now = Date.now();
+ const now = state.now();
if (state._vehicles.size === 0) {
state._moving.clear();
syncRenderHold();
@@ -424,7 +424,7 @@ export function createRendering({ state, services, parts }) {
}
parts.trails.update();
- refreshRotations(now);
+ refreshRotations(Date.now());
const selected = state._vehicles.get(state._selectedKey);
if (selected && state._visible.has(selected)) {
selected.marker.rotation = Number.isFinite(selected.courseDeg)
@@ -489,7 +489,7 @@ export function createRendering({ state, services, parts }) {
const noSample = !entry.sample || !Number.isFinite(entry.sample.lat);
if (entry.track?.count && (!state._visible.has(entry) || noSample)) {
entry.sample ||= {};
- updatePlayback(entry, now, performance.now());
+ updatePlayback(entry, state.now(), performance.now());
}
sphere.center =
state._visible.has(entry) || !entry.sample
@@ -557,7 +557,7 @@ export function createRendering({ state, services, parts }) {
}
function resumePlayback() {
if (!state._enabled) return;
- const now = Date.now(),
+ const now = state.now(),
mono = performance.now();
const entries = new Set(state._visible);
const selected = state._vehicles.get(state._selectedKey);
diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js
index 75b13d84b..5bb152015 100644
--- a/src/layers/transit/state.js
+++ b/src/layers/transit/state.js
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { createHistoryBudget } from '../../data/contactPlayback.js';
+import { worldTimeFor } from '../../time/worldClock.js';
/**
* One Transit layer's mutable scene state. Every field lives on the instance,
@@ -14,6 +15,8 @@ export function createState({ services }) {
const state = {};
state._historyBudget = createHistoryBudget();
+ /** The time vehicles play back behind by their display delay, in epoch ms. */
+ state.now = worldTimeFor('transit');
state.DEFAULT_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js
index a758dddd2..67973bf94 100644
--- a/src/layers/weather/clock.js
+++ b/src/layers/weather/clock.js
@@ -1,19 +1,35 @@
+import { createWorldClock } from '../../time/worldClock.js';
+
export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
-/** Transient observed time. Products must honor the signal before installing a frame. */
+/**
+ * Transient observed time. Products must honor the signal before installing a
+ * frame. Its time is the world clock's: latest while the world is live,
+ * history at the world's time otherwise. Replay steps the observed frames and
+ * moves the world clock as it goes, as the world clock's driver.
+ */
export function createWeatherClock({
now = Date.now,
setTimeout = globalThis.setTimeout,
clearTimeout = globalThis.clearTimeout,
+ world = createWorldClock({ now }),
} = {}) {
const registry = new Map();
const listeners = new Set();
let mode = 'latest';
let target = null;
let playing = false;
+ // The world time last applied; while this clock moves the world, the
+ // transition its move started.
+ let seen = 'latest';
+ let moving = false;
+ let moved = null;
+ const releases = new Map();
+ // Attached at the end, once the API exists.
+ let driver = null;
let generation = 0;
let timer = null;
let pending = null;
@@ -65,11 +81,32 @@ export function createWeatherClock({
timer = null;
};
const canPlay = () => getTimeline().length >= 2;
+ const stopPlaying = () => {
+ if (!playing) return;
+ playing = false;
+ driver?.driven(false);
+ };
+ // Move the world clock; the change comes back through followWorld. Returns
+ // the transition the move started, if it started one.
+ const move = (fn) => {
+ const outer = [moving, moved];
+ moving = true;
+ moved = null;
+ try {
+ fn();
+ return moved;
+ } finally {
+ [moving, moved] = outer;
+ }
+ };
+ const selections = () =>
+ [...registry.values()].map(({ product }) => selectedFor(product)).join();
+ let applied = null;
function schedule() {
cancelTimer();
if (!playing || destroyed || pending) return;
if (!canPlay()) {
- playing = false;
+ stopPlaying();
notify();
return;
}
@@ -92,7 +129,8 @@ export function createWeatherClock({
if (destroyed) return Promise.resolve(false);
cancelTimer();
const owner = ++generation;
- if (!canPlay()) playing = false;
+ if (!canPlay()) stopPlaying();
+ applied = selections();
for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => {
@@ -107,12 +145,13 @@ export function createWeatherClock({
const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false;
pending = null;
- if (!canPlay()) playing = false;
+ if (!canPlay()) stopPlaying();
notify();
schedule();
return true;
});
pending = result;
+ if (moving) moved ??= result;
notify();
return result;
}
@@ -123,11 +162,41 @@ export function createWeatherClock({
throw new Error(`Duplicate weather product: ${product.id}`);
const entry = { product, controller: null };
registry.set(product.id, entry);
+ // The product moves with the world clock, and its frames are time the
+ // world clock's timeline can show while it is on, suspended or not, so
+ // the way back to live stays on screen. Both calls notify the world's
+ // listeners, which may destroy this clock: the release is in place
+ // first, and anything acquired after a destroy is let go at once.
+ let unhold = null;
+ let uncover = null;
+ const release = () => {
+ uncover?.();
+ unhold?.();
+ uncover = unhold = null;
+ };
+ releases.set(product.id, release);
+ unhold = world.hold(product.id);
+ if (!destroyed)
+ uncover = world.register({
+ id: product.id,
+ coverage: () => {
+ const marks = timesFor(product).map((time) => Date.parse(time));
+ return marks.length
+ ? { start: marks[0], end: marks.at(-1), marks }
+ : null;
+ },
+ });
+ if (destroyed) {
+ release();
+ return () => {};
+ }
void api.refresh();
return () => {
if (registry.get(product.id) !== entry) return;
entry.controller?.abort();
registry.delete(product.id);
+ releases.get(product.id)?.();
+ releases.delete(product.id);
void api.refresh();
};
},
@@ -140,9 +209,11 @@ export function createWeatherClock({
const normalized = new Date(time).toISOString();
if (mode === 'history' && target === normalized)
return pending ?? Promise.resolve(true);
- mode = 'history';
- target = normalized;
- return transition();
+ return (
+ move(() => world.seek(Date.parse(normalized))) ??
+ pending ??
+ Promise.resolve(true)
+ );
},
step(direction) {
if (destroyed || ![-1, 1].includes(direction))
@@ -159,10 +230,9 @@ export function createWeatherClock({
},
latest() {
if (destroyed) return Promise.resolve(false);
- mode = 'latest';
- target = null;
- playing = false;
- return transition();
+ stopPlaying();
+ if (mode === 'latest') return transition();
+ return move(() => world.goLive()) ?? pending ?? Promise.resolve(true);
},
play() {
if (destroyed || playing || !canPlay())
@@ -170,18 +240,28 @@ export function createWeatherClock({
playing = true;
if (mode === 'latest') {
const timeline = getTimeline();
- return api.setTarget(
- timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0],
+ const start =
+ timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0];
+ // Playing and at the first frame in one move of the world.
+ return (
+ move(() => driver.driven(true, Date.parse(start))) ??
+ pending ??
+ Promise.resolve(true)
);
}
+ driver.driven(true);
notify();
schedule();
return pending ?? Promise.resolve(true);
},
pause() {
cancelTimer();
- if (!playing) return;
- playing = false;
+ if (!playing) {
+ // The world may be playing on its own; a pause or step here holds it.
+ if (world.isPlaying()) world.pause();
+ return;
+ }
+ stopPlaying();
notify();
},
togglePlay() {
@@ -189,8 +269,10 @@ export function createWeatherClock({
},
refresh() {
if (destroyed) return Promise.resolve(false);
+ // New frames, or products added or dropped.
+ world.coverageChanged();
if (!canPlay()) {
- playing = false;
+ stopPlaying();
cancelTimer();
}
if (mode === 'history') return transition();
@@ -204,15 +286,44 @@ export function createWeatherClock({
},
destroy() {
if (destroyed) return;
+ stopPlaying();
destroyed = true;
++generation;
- playing = false;
cancelTimer();
+ unfollowWorld();
+ // The last weather clock leaves the world live, as a new one expects.
+ if (driver.detach() === 0) world.goLive();
+ for (const release of releases.values()) release();
+ releases.clear();
for (const entry of registry.values()) entry.controller?.abort();
registry.clear();
listeners.clear();
pending = null;
},
};
+ // Follow the world clock. A move of this clock's own always applies; one
+ // from elsewhere (a host's seek, continuous play) applies only when it
+ // changes the mode or a product's frame.
+ function followWorld(state) {
+ if (destroyed) return;
+ const nextMode = state.live ? 'latest' : 'history';
+ const nextTarget = state.live
+ ? null
+ : new Date(Math.floor(state.timeMs)).toISOString();
+ const key = nextTarget ?? 'latest';
+ if (key === seen) return;
+ seen = key;
+ const modeChanged = nextMode !== mode;
+ mode = nextMode;
+ target = nextTarget;
+ if (moving || modeChanged || selections() !== applied) void transition();
+ else notify();
+ }
+ const unfollowWorld = world.subscribe(followWorld);
+ driver = world.attachDriver({
+ play: () => (canPlay() ? void api.play() : false),
+ pause: () => api.pause(),
+ });
+ followWorld(world.getState());
return api;
}
diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs
index 24bd124b8..c803e5d72 100644
--- a/src/layers/weather/clock.test.mjs
+++ b/src/layers/weather/clock.test.mjs
@@ -7,6 +7,7 @@ import {
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
+import { createWorldClock } from '../../time/worldClock.js';
const time = (minutes) =>
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
@@ -37,7 +38,15 @@ function harness(t) {
let at = Date.parse(time(20));
const timers = new Map();
let id = 0;
+ let mono = 0;
+ const world = createWorldClock({
+ now: () => at,
+ monotonicNow: () => mono,
+ setInterval: () => 0,
+ clearInterval() {},
+ });
const clock = createWeatherClock({
+ world,
now: () => at,
setTimeout(fn, delay) {
timers.set(++id, { fn, due: at + delay });
@@ -50,9 +59,11 @@ function harness(t) {
t.after(() => clock.destroy());
return {
clock,
+ world,
timers,
tick(ms) {
at += ms;
+ mono += ms;
for (const [id, timer] of [...timers]) {
if (timer.due > at) continue;
timers.delete(id);
@@ -275,3 +286,210 @@ test('registration joins history; unregister and destroy abort work and release
assert.deepEqual(clock.getTimeline(), []);
assert.equal(await clock.setTarget(time(5)), false);
});
+
+test('the weather time is the world time, both ways', async (t) => {
+ const { clock, world } = harness(t);
+ const radar = product('radar', [0, 10, 20]);
+ clock.register(radar);
+ assert.equal(world.follows('radar'), true);
+ await clock.setTarget(time(10));
+ assert.equal(world.isLive(), false);
+ assert.equal(world.now(), Date.parse(time(10)));
+ world.seek(Date.parse(time(3)));
+ await flush();
+ assert.equal(clock.getState().target, time(3));
+ assert.equal(radar.shown, time(0));
+ world.goLive();
+ await flush();
+ assert.equal(clock.getState().mode, 'latest');
+ assert.equal(radar.shown, time(20));
+});
+
+test('world play and pause drive the weather replay', async (t) => {
+ const { clock, world, tick } = harness(t);
+ const radar = product('radar', [0, 10, 20]);
+ clock.register(radar);
+ world.seek(Date.parse(time(0)));
+ await flush();
+ world.play();
+ assert.equal(clock.getState().playing, true);
+ assert.equal(world.isPlaying(), true);
+ tick(2000);
+ await flush();
+ assert.equal(radar.shown, time(10));
+ assert.equal(world.now(), Date.parse(time(10)));
+ world.pause();
+ assert.equal(clock.getState().playing, false);
+ clock.play();
+ assert.equal(world.isPlaying(), true);
+ clock.pause();
+ assert.equal(world.isPlaying(), false);
+ clock.play();
+ world.goLive();
+ await flush();
+ assert.equal(clock.getState().playing, false);
+ assert.equal(clock.getState().mode, 'latest');
+});
+
+test('a moving world re-applies frames only when a product changes frame', async (t) => {
+ const { clock, world } = harness(t);
+ const radar = product('radar', [0, 10, 20]);
+ clock.register(radar);
+ world.seek(Date.parse(time(0)));
+ await flush();
+ const applied = radar.calls.length;
+ world.seek(Date.parse(time(0)) + 60_000);
+ await flush();
+ assert.equal(radar.calls.length, applied);
+ assert.equal(
+ clock.getState().target,
+ new Date(Date.parse(time(1))).toISOString(),
+ );
+ world.seek(Date.parse(time(11)));
+ await flush();
+ assert.equal(radar.calls.length, applied + 1);
+ assert.equal(radar.shown, time(10));
+});
+
+test('a world without frames to step plays on its own', async (t) => {
+ const { clock, world, tick } = harness(t);
+ world.seek(Date.parse(time(0)));
+ world.play();
+ assert.equal(clock.getState().playing, false);
+ tick(1000);
+ assert.equal(world.now(), Date.parse(time(0)) + 1000);
+});
+
+function worldAt(at) {
+ return createWorldClock({
+ now: () => at,
+ monotonicNow: () => 0,
+ setInterval: () => 0,
+ clearInterval() {},
+ });
+}
+
+test('weather clocks sharing a world hand play over, and the last leaves it live', async () => {
+ const world = worldAt(Date.parse(time(20)));
+ const a = createWeatherClock({ world, now: () => Date.parse(time(20)) });
+ const b = createWeatherClock({ world, now: () => Date.parse(time(20)) });
+ const radarA = product('radar', [0, 10, 20]);
+ const radarB = product('radar', [0, 10, 20]);
+ a.register(radarA);
+ b.register(radarB);
+ await a.play();
+ assert.equal(world.isPlaying(), true);
+ world.pause();
+ assert.equal(a.getState().playing, false);
+ assert.equal(world.isPlaying(), false);
+ await a.play();
+ world.goLive();
+ assert.equal(a.getState().playing, false);
+ b.destroy();
+ assert.equal(world.follows('radar'), true);
+ world.seek(Date.parse(time(0)));
+ world.play();
+ assert.equal(a.getState().playing, true);
+ a.destroy();
+ assert.equal(world.follows('radar'), false);
+ assert.equal(world.isLive(), true);
+ assert.equal(world.isPlaying(), false);
+});
+
+test('pausing from inside the first playing notice stops the world too', async (t) => {
+ const { clock, world } = harness(t);
+ clock.register(product('radar', [0, 10, 20]));
+ const off = clock.subscribe((state) => {
+ if (state.playing) {
+ off();
+ clock.pause();
+ }
+ });
+ await clock.play();
+ assert.equal(clock.getState().playing, false);
+ assert.equal(world.isPlaying(), false);
+});
+
+test('a step holds a world that was playing on its own', async (t) => {
+ const { clock, world } = harness(t);
+ world.seek(Date.parse(time(0)));
+ world.play();
+ clock.register(product('radar', [0, 10, 20]));
+ await clock.step(1);
+ assert.equal(world.isPlaying(), false);
+ assert.equal(world.now(), Date.parse(time(10)));
+});
+
+test('a target superseded from a listener resolves false', async (t) => {
+ const { clock } = harness(t);
+ const radar = product('radar', [0, 10, 20]);
+ clock.register(radar);
+ await flush();
+ let redirected = false;
+ clock.subscribe((state) => {
+ if (state.target === time(0) && !redirected) {
+ redirected = true;
+ void clock.setTarget(time(10));
+ }
+ });
+ assert.equal(await clock.setTarget(time(0)), false);
+ assert.equal(radar.shown, time(10));
+});
+
+test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => {
+ const { clock, world } = harness(t);
+ assert.equal(world.coverage(), null);
+ assert.equal(world.getState().supported, false);
+ const radar = product('radar', [0, 10]);
+ const unregister = clock.register(radar);
+ clock.register(product('lightning', [5]));
+ assert.deepEqual(world.coverage(), {
+ start: Date.parse(time(0)),
+ end: Date.parse(time(10)),
+ marks: [0, 5, 10].map((m) => Date.parse(time(m))),
+ });
+ assert.equal(world.getState().supported, true);
+ unregister();
+ assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]);
+ clock.destroy();
+ assert.equal(world.coverage(), null);
+ assert.equal(world.getState().supported, false);
+});
+
+test('a suspended product keeps its coverage, so the way back to live stays', (t) => {
+ const { clock, world } = harness(t);
+ const radar = product('radar', [0, 10]);
+ clock.register(radar);
+ radar.suspended = true;
+ void clock.refresh();
+ assert.equal(world.getState().supported, true);
+ assert.deepEqual(
+ world.coverage().marks,
+ [0, 10].map((m) => Date.parse(time(m))),
+ );
+});
+
+test('new frames reach the world clock through refresh', (t) => {
+ const { clock, world } = harness(t);
+ const radar = product('radar', []);
+ clock.register(radar);
+ const supported = [];
+ world.subscribe((state) => supported.push(state.supported));
+ assert.equal(world.getState().supported, false);
+ radar.times = [time(0)];
+ void clock.refresh();
+ assert.equal(supported.at(-1), true);
+});
+
+test('a clock destroyed while it registers a product lets go of it', (t) => {
+ const { clock, world } = harness(t);
+ const off = world.subscribe(() => {
+ off();
+ clock.destroy();
+ });
+ const unregister = clock.register(product('radar', [0, 10]));
+ unregister();
+ assert.equal(world.follows('radar'), false);
+ assert.equal(world.coverage(), null);
+ assert.equal(world.getState().supported, false);
+});
diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js
index e318e3087..4ccfdef6e 100644
--- a/src/layers/weather/index.js
+++ b/src/layers/weather/index.js
@@ -52,7 +52,8 @@ const utc = (value) =>
: t('Unavailable');
/** A per-application observation layer, using the existing layer lifecycle and
- * row controls. History is transient: shared links always open latest imagery. */
+ * row controls. Its time is the world clock's, so a link made away from live
+ * opens its imagery at that time; one made live opens the latest. */
export function createWeatherLayer({
feed,
clock,
diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js
index 809e0a57b..4e42ce9be 100644
--- a/src/share/viewCodec.js
+++ b/src/share/viewCodec.js
@@ -31,6 +31,7 @@ import {
} from '../data/detectionPolicy.js';
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { VIEW_STYLES } from '../view/index.js';
+import { isWorldTime } from '../time/worldClock.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
import {
GLOBAL_POST_DEFAULTS,
@@ -375,6 +376,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([
{ number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 },
{ number: 26, key: 'scopeTerminusPct', type: 'uint', default: null },
{ number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE },
+ { number: 28, key: 'worldTime', type: 'epoch', default: null },
]);
const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f]));
const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
@@ -734,6 +736,9 @@ export function normalizeShareViewState(input = {}) {
: null;
state.panelState = normalizePanelState(source.panelState);
state.sharedAtMs = epochMsOr(source.sharedAtMs);
+ state.worldTime = isWorldTime(source.worldTime)
+ ? Math.floor(source.worldTime / 1000) * 1000
+ : null;
let view = normalizeView(source.view);
if (state.layerState) view = liftTrackedEntity(state.layerState, view);
if (view.kind === 'thing') {
@@ -950,8 +955,9 @@ export function encodeShareView(input) {
writer.sintField(field.number, signedAngle(state[field.key]));
break;
case 'sharedAtMs':
- if (state.sharedAtMs !== null)
- writer.sintField(field.number, epochSeconds(state.sharedAtMs));
+ case 'worldTime':
+ if (state[field.key] !== null)
+ writer.sintField(field.number, epochSeconds(state[field.key]));
break;
case 'flags': {
let bits = 0;
@@ -1349,6 +1355,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
raw.sharedAtMs =
createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null;
}
+ // The world time the link was made at, when it was away from live. It
+ // may lie ahead of the link's creation (a forecast) or of now.
+ if (get('worldTime')) {
+ const ms = (SHARE_VIEW_EPOCH_S + unz(get('worldTime').varint)) * 1000;
+ raw.worldTime = isWorldTime(ms) ? ms : null;
+ }
if (get('layerState'))
raw.layerState = decodeLayers(
diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs
index 6cc6fd9b1..f9fd81d38 100644
--- a/src/share/viewCodec.test.mjs
+++ b/src/share/viewCodec.test.mjs
@@ -361,6 +361,7 @@ test('every registered field round-trips at once', () => {
})),
},
sharedAtMs: NOW_MS,
+ worldTime: NOW_MS - 6 * 3_600_000,
};
// A thing view derives its camera (roll 0), so the camera fields are
// checked on the same state as a place view.
@@ -501,6 +502,7 @@ test('the field registry is well formed and permanent', () => {
[25, 'scopeFeatherPct', 100],
[26, 'scopeTerminusPct', null],
[27, 'styleParams', BY_STYLE],
+ [28, 'worldTime', null],
],
);
assert.deepEqual(
@@ -1338,3 +1340,22 @@ test('a layer option filed under a mirror layer is ignored', () => {
{ scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` },
]);
});
+
+test('the world time is written only away from live, and may lie ahead', () => {
+ const at = (worldTime) =>
+ decodeShareView(encodeShareView({ sharedAtMs: NOW_MS, worldTime }), {
+ nowMs: NOW_MS,
+ }).state.worldTime;
+ assert.equal(at(null), null);
+ assert.equal(at(NOW_MS - 3_600_000), NOW_MS - 3_600_000);
+ // A forecast moment ahead of the link's creation is kept.
+ assert.equal(at(NOW_MS + 86_400_000), NOW_MS + 86_400_000);
+ // Before the 2026 epoch too: the field is zigzag.
+ assert.equal(at(Date.UTC(2024, 0, 1)), Date.UTC(2024, 0, 1));
+ // Past what a Date can hold: dropped, so the link opens live.
+ assert.equal(at(8.8e15), null);
+ assert.ok(
+ encodeShareView({ sharedAtMs: NOW_MS, worldTime: NOW_MS - 1000 }).length >
+ encodeShareView({ sharedAtMs: NOW_MS }).length,
+ );
+});
diff --git a/src/sharelink.js b/src/sharelink.js
index 4a0291a84..fcef04ea5 100644
--- a/src/sharelink.js
+++ b/src/sharelink.js
@@ -5,6 +5,7 @@ import {
normalizeAllocationStrategy,
} from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.js';
+import { worldClock as pageWorldClock } from './time/worldClock.js';
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
import {
decodeLayerStateParams,
@@ -68,9 +69,11 @@ export class ShareLinkManager {
isNavigationCurrent,
cancelOwnedNavigation,
initialShareCode = null,
+ worldClock = pageWorldClock,
} = {},
) {
this.viewer = viewer;
+ this._worldClock = worldClock;
// A host may hand over the share code to open instead of the address.
this._initialShareCode =
typeof initialShareCode === 'string' ? initialShareCode : null;
@@ -163,8 +166,9 @@ export class ShareLinkManager {
/**
* The restore state for a share code, in the long form's parsed shape plus
- * `shareView` (what the view follows, with its offset and last-known fix),
- * or null when the code is refused.
+ * `shareView` (what the view follows, with its offset and last-known fix)
+ * and `worldTime` (null when the link was made live), or null when the
+ * code is refused.
*/
_parseShareCode(code) {
const result = decodeShareView(code);
@@ -174,6 +178,7 @@ export class ShareLinkManager {
}
const state = toLegacyShareState(result.state);
state.shareView = result.state.view;
+ state.worldTime = result.state.worldTime;
return state;
}
@@ -592,6 +597,10 @@ export class ShareLinkManager {
// that state in the share format.
const state = fromLegacyShareState(this._parseShareHash(params.toString()));
state.sharedAtMs = Math.floor(nowMs / 1000) * 1000;
+ // Away from live, the link opens at the same world time.
+ state.worldTime = this._worldClock.isLive()
+ ? null
+ : Math.floor(this._worldClock.now() / 1000) * 1000;
let subject = null;
try {
subject = this._followSubjectProvider?.({ nowMs }) ?? null;
diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs
index e4e70b629..e486ba58d 100644
--- a/src/sharelink.navigation.test.mjs
+++ b/src/sharelink.navigation.test.mjs
@@ -423,3 +423,28 @@ test('the layer coordinator replaces in-flight share layers with the navigated s
);
coordinator.destroy();
});
+
+test('a link made away from live carries its world time to the page that opens it', (t) => {
+ installTab(t, SHARE_A);
+ const worldTime = Date.UTC(2026, 9, 30, 6, 15, 42);
+ const sharer = new ShareLinkManager(fakeViewer(), {
+ worldClock: { isLive: () => false, now: () => worldTime + 400 },
+ });
+ const link = sharer.buildLink({ nowMs: Date.UTC(2026, 10, 1) });
+ sharer.destroy();
+ const code = new URL(link).pathname.split('/').at(-1);
+ const opener = new ShareLinkManager(fakeViewer(), { initialShareCode: code });
+ assert.equal(opener.parseInitialHash().worldTime, worldTime);
+ opener.destroy();
+
+ const live = new ShareLinkManager(fakeViewer(), {
+ worldClock: { isLive: () => true, now: () => worldTime },
+ });
+ const liveCode = new URL(live.buildLink()).pathname.split('/').at(-1);
+ live.destroy();
+ const liveOpener = new ShareLinkManager(fakeViewer(), {
+ initialShareCode: liveCode,
+ });
+ assert.equal(liveOpener.parseInitialHash().worldTime, null);
+ liveOpener.destroy();
+});
diff --git a/src/time/julian.js b/src/time/julian.js
new file mode 100644
index 000000000..b63a53621
--- /dev/null
+++ b/src/time/julian.js
@@ -0,0 +1,13 @@
+import * as Cesium from 'cesium';
+
+const scratch = new Date();
+
+/**
+ * Epoch ms as a Cesium JulianDate, like `JulianDate.now(result)` for any time.
+ * @param {number} ms
+ * @param {Cesium.JulianDate} [result]
+ */
+export function julianAt(ms, result) {
+ scratch.setTime(ms);
+ return Cesium.JulianDate.fromDate(scratch, result);
+}
diff --git a/src/time/viewerClock.js b/src/time/viewerClock.js
new file mode 100644
index 000000000..e4c456d42
--- /dev/null
+++ b/src/time/viewerClock.js
@@ -0,0 +1,46 @@
+import * as Cesium from 'cesium';
+
+/**
+ * Draw Cesium's sky at the world time: the sun, moon, stars, atmosphere and
+ * the lighting on the tiles all read `frameState.time`, which Cesium takes
+ * from its own clock. That clock never animates in GEV, so before this the
+ * sky stayed at the moment the page loaded.
+ *
+ * Each frame's time is set in `preUpdate`, before the scene stamps the frame
+ * with it, so the sky is drawn at the same moment the layers read that frame.
+ * The clock's `currentTime` follows too, for anything that samples it.
+ * @param {Cesium.Viewer} viewer
+ * @param {() => number} readTime Epoch ms the sky is drawn at.
+ * @param {{subscribe?: Function, requestRender?: () => void}} [options]
+ * A world clock to hear moves from, so a resting scene repaints when the
+ * world time jumps.
+ * @returns {() => void} Stop following.
+ */
+export function followWorldTime(
+ viewer,
+ readTime,
+ { subscribe, requestRender = () => viewer.scene.requestRender() } = {},
+) {
+ const date = new Date();
+ const removePreUpdate = viewer.scene.preUpdate.addEventListener(
+ (_scene, time) => {
+ date.setTime(readTime());
+ const clock = viewer.clock;
+ Cesium.JulianDate.fromDate(date, clock.currentTime);
+ // The frame's own time: Cesium stamps the frame with this object next.
+ if (time) Cesium.JulianDate.clone(clock.currentTime, time);
+ },
+ );
+ let last = readTime();
+ const unsubscribe =
+ subscribe?.(() => {
+ const next = readTime();
+ if (next === last) return;
+ last = next;
+ requestRender();
+ }) ?? (() => {});
+ return () => {
+ removePreUpdate();
+ unsubscribe();
+ };
+}
diff --git a/src/time/worldClock.js b/src/time/worldClock.js
new file mode 100644
index 000000000..a9be9296d
--- /dev/null
+++ b/src/time/worldClock.js
@@ -0,0 +1,349 @@
+/**
+ * The world's one time, in epoch ms. Every layer that draws something at a
+ * moment reads it here instead of calling `Date.now()` itself, so layers can
+ * never disagree about when the world is.
+ *
+ * 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
+ * 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
+ * it goes, so play and pause hand over to it. Several drivers may be attached;
+ * play goes to the newest that can play, pause to the one playing.
+ */
+
+/** The span a JS Date can hold, either side of 1970. */
+const MAX_DATE_MS = 8.64e15;
+
+/** Whether `ms` is a time the world clock can hold. */
+export const isWorldTime = (ms) =>
+ Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS;
+
+/** How often subscribers hear of a continuously playing cursor. */
+export const WORLD_CLOCK_TICK_MS = 250;
+
+/**
+ * @param {{now?: () => number, monotonicNow?: () => number,
+ * setInterval?: typeof globalThis.setInterval,
+ * 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)
+ // reaches every layer as it did when they called Date.now themselves.
+ now = () => Date.now(),
+ 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(follow);
+ const holds = new Map();
+ const readers = new Map();
+ const sources = new Set();
+ const drivers = [];
+ let live = true;
+ // The cursor while fixed; while playing continuously, the cursor at `anchorMono`.
+ let cursorMs = null;
+ let anchorMono = 0;
+ let rate = 1;
+ let playing = false;
+ // The driver handle stepping the clock, or null.
+ let active = null;
+ let ticker = null;
+ let notifying = 0;
+
+ const continuous = () => playing && !active;
+ const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
+ const followedIds = () => [
+ ...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
+ ];
+ // A host's coverage that throws counts as none, so it cannot break the
+ // listeners every state reaches.
+ const spanOf = (source) => {
+ let span;
+ try {
+ span = source.coverage();
+ } catch (error) {
+ console.error(`World time coverage failed: ${source.id}`, error);
+ return null;
+ }
+ return span && span.end >= span.start ? span : null;
+ };
+ // Whether a layer that moves with the clock has time to show: only then is
+ // there anything for a timeline to play.
+ const isSupported = () => {
+ for (const { source } of sources)
+ if (isFollowed(source.id) && spanOf(source)) return true;
+ return false;
+ };
+ const time = () => {
+ if (live) return now();
+ if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate;
+ return cursorMs;
+ };
+ const getState = () =>
+ Object.freeze({
+ live,
+ timeMs: time(),
+ playing,
+ rate,
+ following: followedIds(),
+ supported: isSupported(),
+ });
+ // Each listener hears the state as it is when called. A change made from
+ // inside a listener notifies everyone afresh, and the outer round stops, so
+ // nobody is left holding a state that has since changed.
+ // A round's state is read once: any change made during it starts a new one.
+ const notify = () => {
+ const round = ++notifying;
+ let state = null;
+ for (const listener of [...listeners]) {
+ if (round !== notifying) return;
+ if (!listeners.has(listener)) continue;
+ try {
+ listener((state ??= getState()));
+ } catch (error) {
+ console.error('World clock listener failed', error);
+ }
+ }
+ };
+ const syncTicker = () => {
+ if (continuous() && listeners.size) {
+ ticker ??= setInterval(notify, WORLD_CLOCK_TICK_MS);
+ } else if (ticker !== null) {
+ clearInterval(ticker);
+ ticker = null;
+ }
+ };
+ const changed = () => {
+ syncTicker();
+ notify();
+ };
+ // Freeze a continuously playing cursor where it is.
+ const settle = () => {
+ if (continuous()) cursorMs = time();
+ anchorMono = monotonicNow();
+ };
+
+ const api = {
+ /** The live edge: the wall clock, whatever the cursor does. */
+ wallNow: () => now(),
+ /** The world time: the wall clock while live, else the cursor. */
+ now: time,
+ isLive: () => live,
+ isPlaying: () => playing,
+ getRate: () => rate,
+
+ /** Hold the world at `ms`; a playing clock plays on from there. */
+ seek(ms) {
+ if (!isWorldTime(ms)) throw new TypeError('World time must be epoch ms');
+ if (!live && cursorMs === ms && !continuous()) return;
+ live = false;
+ cursorMs = ms;
+ anchorMono = monotonicNow();
+ changed();
+ },
+ /** Back to the wall clock; playback stops. */
+ goLive() {
+ if (live && !playing) return;
+ const stepping = active;
+ live = true;
+ cursorMs = null;
+ playing = false;
+ active = null;
+ // Its driven(false) finds nothing left to stop.
+ stepping?.driver.pause();
+ changed();
+ },
+ /** Play from the cursor (from now, when live). */
+ play({ rate: nextRate } = {}) {
+ if (nextRate !== undefined) api.setRate(nextRate);
+ if (playing) return;
+ for (let index = drivers.length - 1; index >= 0; index -= 1) {
+ if (drivers[index].driver.play() !== false) return;
+ }
+ if (live) {
+ live = false;
+ cursorMs = now();
+ }
+ playing = true;
+ anchorMono = monotonicNow();
+ changed();
+ },
+ pause() {
+ if (!playing) return;
+ if (active) {
+ active.driver.pause();
+ return;
+ }
+ settle();
+ playing = false;
+ changed();
+ },
+ togglePlay() {
+ return playing ? api.pause() : api.play();
+ },
+ /** Simulated ms per real ms for continuous playback. */
+ setRate(nextRate) {
+ if (!Number.isFinite(nextRate) || nextRate <= 0)
+ throw new RangeError('World clock rate must be positive');
+ if (nextRate === rate) return;
+ settle();
+ rate = nextRate;
+ changed();
+ },
+
+ /**
+ * Offer a stepping player. The driver moves the clock with `seek()` and
+ * reports through its handle's `driven(playing, atMs?)`; its `play()`
+ * returns false when it cannot play, and the clock tries an older driver
+ * or plays continuously instead.
+ * @param {{play: () => (boolean|void), pause: () => void}} driver
+ * @returns {{driven: (playing: boolean, atMs?: number) => void,
+ * detach: () => number}} `detach()` returns how many drivers remain.
+ */
+ attachDriver(driver) {
+ const handle = {
+ driver,
+ /** This driver started stepping (at `atMs`, if given) or stopped. */
+ driven(isPlaying, atMs) {
+ if (!drivers.includes(handle)) return;
+ if (!isPlaying) {
+ if (active !== handle) return;
+ active = null;
+ playing = false;
+ changed();
+ return;
+ }
+ // A continuous run hands over where it is; another driver stops.
+ settle();
+ const previous = active;
+ active = handle;
+ playing = true;
+ live = false;
+ if (Number.isFinite(atMs)) cursorMs = atMs;
+ else cursorMs ??= now();
+ anchorMono = monotonicNow();
+ if (previous && previous !== handle) previous.driver.pause();
+ changed();
+ },
+ detach() {
+ const index = drivers.indexOf(handle);
+ if (index < 0) return drivers.length;
+ handle.driven(false);
+ drivers.splice(index, 1);
+ return drivers.length;
+ },
+ };
+ drivers.push(handle);
+ return handle;
+ },
+
+ /** Let layer `id` move with the cursor instead of reading the live edge. */
+ follow(id, on = true) {
+ if (on === following.has(id)) return;
+ if (on) following.add(id);
+ else following.delete(id);
+ changed();
+ },
+ /**
+ * Keep layer `id` following until the returned release is called, however
+ * many parts of the app hold it.
+ */
+ hold(id) {
+ const was = isFollowed(id);
+ holds.set(id, (holds.get(id) ?? 0) + 1);
+ if (!was) changed();
+ let held = true;
+ return () => {
+ if (!held) return;
+ held = false;
+ const count = holds.get(id) - 1;
+ if (count > 0) holds.set(id, count);
+ else holds.delete(id);
+ if (!isFollowed(id)) changed();
+ };
+ },
+ follows: isFollowed,
+ /**
+ * The time layer `id` draws at: the cursor when it follows the clock,
+ * otherwise the live edge.
+ * @returns {() => number}
+ */
+ timeFor(id) {
+ let reader = readers.get(id);
+ if (!reader) {
+ reader = () => (isFollowed(id) ? time() : now());
+ readers.set(id, reader);
+ }
+ return reader;
+ },
+
+ /**
+ * A layer's span of time it can show, for a host's timeline; null while
+ * it has none (off, or nothing loaded). Returns the unregister.
+ * @param {{id: string, coverage: () => ({start: number, end: number, marks?: number[]} | null)}} source
+ */
+ register(source) {
+ // Each registration is its own, even under a shared id.
+ const entry = { source };
+ sources.add(entry);
+ changed();
+ return () => {
+ if (sources.delete(entry)) changed();
+ };
+ },
+ /** A source's coverage changed (frames loaded, the layer paused). */
+ coverageChanged: () => changed(),
+ /** Whether a followed layer has time to show; see getState().supported. */
+ isSupported,
+ /** The union of the registered sources' coverage, or null with none. */
+ coverage() {
+ let start = Infinity;
+ let end = -Infinity;
+ const marks = new Set();
+ for (const { source } of sources) {
+ const span = spanOf(source);
+ if (!span) continue;
+ start = Math.min(start, span.start);
+ end = Math.max(end, span.end);
+ for (const mark of span.marks ?? []) marks.add(mark);
+ }
+ if (start > end) return null;
+ return { start, end, marks: [...marks].sort((a, b) => a - b) };
+ },
+
+ getState,
+ subscribe(listener) {
+ listeners.add(listener);
+ syncTicker();
+ return () => {
+ listeners.delete(listener);
+ syncTicker();
+ };
+ },
+ };
+ return api;
+}
+
+/**
+ * The page's world clock. Layers, the sky and a host all read this one. The
+ * sky and the HUD's clock show the world time; 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', 'hud', '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
new file mode 100644
index 000000000..19bc8ba93
--- /dev/null
+++ b/src/time/worldClock.test.mjs
@@ -0,0 +1,307 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createWorldClock, WORLD_CLOCK_TICK_MS } from './worldClock.js';
+
+function fakeTime() {
+ const t = {
+ wall: Date.UTC(2026, 10, 1, 12),
+ mono: 1_000,
+ timers: new Map(),
+ nextId: 1,
+ advance(ms) {
+ t.wall += ms;
+ t.mono += ms;
+ },
+ options: {
+ now: () => t.wall,
+ monotonicNow: () => t.mono,
+ setInterval: (fn, ms) => {
+ const id = t.nextId++;
+ t.timers.set(id, { fn, ms });
+ return id;
+ },
+ clearInterval: (id) => t.timers.delete(id),
+ },
+ };
+ return t;
+}
+
+test('live is the wall clock for every layer', () => {
+ const t = fakeTime();
+ const clock = createWorldClock(t.options);
+ assert.equal(clock.isLive(), true);
+ assert.equal(clock.now(), t.wall);
+ t.advance(5_000);
+ assert.equal(clock.now(), t.wall);
+ assert.equal(clock.timeFor('flights')(), t.wall);
+ clock.follow('flights');
+ assert.equal(clock.timeFor('flights')(), t.wall);
+});
+
+test('away from live only following layers move with the cursor', () => {
+ const t = fakeTime();
+ const clock = createWorldClock(t.options);
+ const satellites = clock.timeFor('satellites');
+ const flights = clock.timeFor('flights');
+ clock.follow('satellites');
+ const past = t.wall - 3_600_000;
+ clock.seek(past);
+ assert.equal(clock.isLive(), false);
+ assert.equal(clock.now(), past);
+ assert.equal(satellites(), past);
+ assert.equal(flights(), t.wall);
+ assert.equal(clock.timeFor('satellites'), satellites);
+ clock.goLive();
+ 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', 'hud', '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);
+ const start = t.wall - 60_000;
+ clock.seek(start);
+ clock.play({ rate: 60 });
+ t.advance(1_000);
+ assert.equal(clock.now(), start + 60_000);
+ clock.setRate(120);
+ t.advance(1_000);
+ assert.equal(clock.now(), start + 180_000);
+ clock.pause();
+ t.advance(1_000);
+ assert.equal(clock.now(), start + 180_000);
+ assert.equal(clock.isPlaying(), false);
+});
+
+test('play from live starts at now, and goLive returns to the wall clock', () => {
+ const t = fakeTime();
+ const clock = createWorldClock(t.options);
+ const from = t.wall;
+ clock.play({ rate: 2 });
+ assert.equal(clock.isLive(), false);
+ t.advance(1_000);
+ assert.equal(clock.now(), from + 2_000);
+ clock.goLive();
+ assert.equal(clock.isPlaying(), false);
+ assert.equal(clock.now(), t.wall);
+});
+
+test('subscribers hear changes, and ticks only while playing continuously', () => {
+ const t = fakeTime();
+ const clock = createWorldClock(t.options);
+ const states = [];
+ const off = clock.subscribe((state) => states.push(state));
+ clock.seek(t.wall - 1_000);
+ assert.equal(states.length, 1);
+ assert.equal(states[0].live, false);
+ assert.equal(t.timers.size, 0);
+ clock.play();
+ assert.equal(t.timers.size, 1);
+ assert.equal([...t.timers.values()][0].ms, WORLD_CLOCK_TICK_MS);
+ clock.pause();
+ assert.equal(t.timers.size, 0);
+ clock.play();
+ off();
+ assert.equal(t.timers.size, 0);
+});
+
+test('a driver takes play and pause, and moves the clock with seek', () => {
+ const t = fakeTime();
+ const clock = createWorldClock(t.options);
+ const calls = [];
+ const handle = clock.attachDriver({
+ play() {
+ calls.push('play');
+ handle.driven(true, t.wall - 10_000);
+ },
+ pause() {
+ calls.push('pause');
+ handle.driven(false);
+ },
+ });
+ clock.play();
+ assert.deepEqual(calls, ['play']);
+ assert.equal(clock.isPlaying(), true);
+ assert.equal(clock.now(), t.wall - 10_000);
+ const at = clock.now();
+ t.advance(5_000);
+ // Stepped, not continuous: the cursor waits for the driver.
+ assert.equal(clock.now(), at);
+ clock.pause();
+ assert.deepEqual(calls, ['play', 'pause']);
+ assert.equal(clock.isPlaying(), false);
+ clock.play();
+ clock.goLive();
+ assert.deepEqual(calls, ['play', 'pause', 'play', 'pause']);
+ assert.equal(clock.isLive(), true);
+ assert.equal(clock.isPlaying(), false);
+ assert.equal(handle.detach(), 0);
+ clock.play();
+ assert.equal(calls.length, 4);
+ t.advance(1_000);
+ assert.equal(clock.now(), t.wall);
+});
+
+test('play goes to the newest driver that can play, pause to the one playing', () => {
+ const t = fakeTime();
+ const clock = createWorldClock(t.options);
+ const calls = [];
+ const driver = (name, canPlay) => {
+ const handle = clock.attachDriver({
+ play() {
+ if (!canPlay()) return false;
+ calls.push(`${name} play`);
+ handle.driven(true);
+ },
+ pause() {
+ calls.push(`${name} pause`);
+ handle.driven(false);
+ },
+ });
+ return handle;
+ };
+ let bCanPlay = false;
+ const a = driver('a', () => true);
+ const b = driver('b', () => bCanPlay);
+ clock.seek(t.wall - 1_000);
+ clock.play();
+ assert.deepEqual(calls, ['a play']);
+ // b starting takes over from a.
+ bCanPlay = true;
+ b.driven(true);
+ assert.deepEqual(calls, ['a play', 'a pause']);
+ clock.pause();
+ assert.deepEqual(calls, ['a play', 'a pause', 'b pause']);
+ assert.equal(clock.isPlaying(), false);
+ // A detached driver stops, and the other one plays next.
+ clock.play();
+ assert.equal(b.detach(), 1);
+ assert.equal(clock.isPlaying(), false);
+ clock.play();
+ assert.equal(calls.at(-1), 'a play');
+ assert.equal(a.detach(), 0);
+ assert.equal(clock.isPlaying(), false);
+});
+
+test('a listener that changes the clock leaves nobody with a stale state', () => {
+ const t = fakeTime();
+ const clock = createWorldClock(t.options);
+ const seen = [];
+ clock.subscribe((state) => {
+ if (state.timeMs === 0) clock.seek(1_000);
+ });
+ clock.subscribe((state) => seen.push(state.timeMs));
+ clock.seek(0);
+ assert.deepEqual(seen, [1_000]);
+});
+
+test('holds keep a layer following until every holder releases it', () => {
+ const clock = createWorldClock(fakeTime().options);
+ const first = clock.hold('radar');
+ const second = clock.hold('radar');
+ first();
+ first();
+ assert.equal(clock.follows('radar'), true);
+ second();
+ assert.equal(clock.follows('radar'), false);
+ clock.follow('radar');
+ const third = clock.hold('radar');
+ third();
+ assert.equal(clock.follows('radar'), true);
+ assert.deepEqual(clock.getState().following, ['radar']);
+});
+
+test('a driver that cannot play leaves continuous playback', () => {
+ const t = fakeTime();
+ const clock = createWorldClock(t.options);
+ clock.attachDriver({ play: () => false, pause() {} });
+ clock.seek(t.wall - 10_000);
+ clock.play();
+ assert.equal(clock.isPlaying(), true);
+ t.advance(1_000);
+ // Wall and cursor moved the same second at rate 1.
+ assert.equal(clock.now(), t.wall - 10_000);
+});
+
+test('coverage is the union of the registered sources', () => {
+ const clock = createWorldClock(fakeTime().options);
+ assert.equal(clock.coverage(), null);
+ const off = clock.register({
+ id: 'radar',
+ coverage: () => ({ start: 100, end: 200, marks: [100, 150, 200] }),
+ });
+ clock.register({ id: 'satellites', coverage: () => ({ start: 50, end: 180 }) });
+ clock.register({ id: 'empty', coverage: () => null });
+ assert.deepEqual(clock.coverage(), {
+ start: 50,
+ end: 200,
+ marks: [100, 150, 200],
+ });
+ off();
+ assert.deepEqual(clock.coverage(), { start: 50, end: 180, marks: [] });
+});
+
+test('seek refuses anything but epoch ms and rate must be positive', () => {
+ const clock = createWorldClock(fakeTime().options);
+ assert.throws(() => clock.seek(Number.NaN), TypeError);
+ assert.throws(() => clock.setRate(0), RangeError);
+});
+
+test('seek refuses a time no Date can hold', () => {
+ const clock = createWorldClock(fakeTime().options);
+ assert.throws(() => clock.seek(8.8e15), TypeError);
+ clock.seek(-8.64e15);
+ assert.equal(clock.now(), -8.64e15);
+});
+
+test('only a followed layer with time to show supports the timeline', () => {
+ const clock = createWorldClock(fakeTime().options);
+ let span = null;
+ const states = [];
+ clock.subscribe((state) => states.push(state.supported));
+ const off = clock.register({ id: 'satellites', coverage: () => span });
+ span = { start: 0, end: 10 };
+ clock.coverageChanged();
+ assert.equal(clock.isSupported(), false, 'covered but not followed');
+ clock.follow('satellites');
+ assert.equal(clock.isSupported(), true);
+ assert.equal(states.at(-1), true);
+ span = null;
+ clock.coverageChanged();
+ assert.equal(states.at(-1), false);
+ span = { start: 0, end: 10 };
+ off();
+ assert.equal(clock.isSupported(), false);
+ assert.equal(states.at(-1), false);
+});
+
+test('a coverage that throws counts as none', (t) => {
+ t.mock.method(console, 'error', () => {});
+ const clock = createWorldClock(fakeTime().options);
+ clock.follow('broken');
+ clock.register({
+ id: 'broken',
+ coverage() {
+ throw new Error('no');
+ },
+ });
+ assert.equal(clock.getState().supported, false);
+ assert.equal(clock.coverage(), null);
+});
diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs
index 75746b6e3..4fecad9e7 100644
--- a/src/ui/cyberTheme.test.mjs
+++ b/src/ui/cyberTheme.test.mjs
@@ -434,12 +434,12 @@ test('Cyber lower-left telemetry tracks the credit anchor with clearance for its
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/,
);
- const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/);
+ const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/);
assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
const credits = read('./styles/command-dock-trays.css');
const creditOffsets = [
...credits.matchAll(
- /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g,
+ /#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/g,
),
];
assert.ok(creditOffsets.length > 0);
@@ -738,7 +738,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
);
assert.match(
cyberStyles,
- /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/,
+ /#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/,
);
assert.match(
cyberStyles,
@@ -793,7 +793,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
cyberStyles,
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/,
);
- assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/);
+ assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/);
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
assert.match(cyberStyles, /data-layer-id='satellites'/);
assert.match(cyberStyles, /#search-toggle::before/);
diff --git a/src/ui/mobileShell.test.mjs b/src/ui/mobileShell.test.mjs
index a391a6ba7..87286240f 100644
--- a/src/ui/mobileShell.test.mjs
+++ b/src/ui/mobileShell.test.mjs
@@ -586,7 +586,7 @@ test('the phone shell keeps the required attribution visible', () => {
};
const lift = (block) =>
Number(
- /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1],
+ /bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px \+ var\(--world-timeline-lift, 0px\)\)/.exec(block)?.[1],
);
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
const idleCredit = rule(
diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js
index dddedf575..ee0d5541b 100644
--- a/src/ui/panelLayoutController.js
+++ b/src/ui/panelLayoutController.js
@@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [
'#gev-voice-control',
'#pp-toggles',
'#param-slider-panel',
+ '#world-timeline',
].join(', ');
/**
* Fixed UI regions that can occupy the right control lane. Runtime rectangle
@@ -59,6 +60,7 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [
'#cesium-credits .cesium-credit-textContainer',
'#command-dock',
'#gev-voice-control',
+ '#world-timeline',
].join(', ');
export class PanelLayoutController {
constructor({
diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js
index 12f09c18b..9fc4c6f45 100644
--- a/src/ui/shareRestoration.js
+++ b/src/ui/shareRestoration.js
@@ -4,6 +4,16 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { t, msg, formatDate } from '../i18n/index.js';
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
import { UiLifetime } from './uiLifetime.js';
+import { isWorldTime, worldClock } from '../time/worldClock.js';
+
+// A link made away from live opens at its world time, paused. One without a
+// world time leaves the clock as it is: a page opens live, and Back or Forward
+// through long-form hashes (which never carry one) does not stop a replay.
+function applySharedWorldTime(state) {
+ if (!isWorldTime(state?.worldTime)) return;
+ worldClock.pause();
+ worldClock.seek(state.worldTime);
+}
/** How long a shared vessel may take to appear in the vessel feed. */
export const SHARED_VESSEL_WAIT_MS = 120_000;
@@ -146,6 +156,7 @@ export class ShareRestoration {
: Promise.resolve({ status: 'not-requested', share: null, layers: [] });
if (savedState) {
this._hasShareState = true;
+ applySharedWorldTime(savedState);
// Reserve camera authority now; the delayed mesh-friendly flight may
// run only if no newer user, voice, or tracking navigation has won.
this._initialShareNavigationGeneration =
@@ -301,6 +312,7 @@ export class ShareRestoration {
layers: [],
});
this._initialShareState = state;
+ applySharedWorldTime(state);
this._initialShareSelectionSuperseded = false;
this._hasShareState = true;
this._navigatedShareRestoreActive = true;
diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js
new file mode 100644
index 000000000..62fb4528c
--- /dev/null
+++ b/src/ui/stores/worldTime.js
@@ -0,0 +1,83 @@
+import { defineStore } from 'pinia';
+import { onScopeDispose, shallowRef } from 'vue';
+import { worldClock } from '../../time/worldClock.js';
+
+/** How often a shown timeline moves its readout and its end on with now. */
+const LIVE_TICK_MS = 1000;
+/** A scrub this close to now, as a share of the track, is a scrub to live. */
+const LIVE_EDGE = 0.005;
+/** The least that counts as at now, past the readout's own second. */
+const LIVE_EDGE_MS = 5000;
+
+/**
+ * The world clock as the timeline shows it: `{live, playing, timeMs,
+ * supported, span}`, where `supported` says a layer that moves with the clock
+ * has time to show and `span` is the stretch the timeline runs over: the
+ * time those layers cover, reaching to now and to the world time, with their
+ * marks. It follows the page's world clock, which tells it of every move, and
+ * of the time a few times a second while it plays on its own. While a
+ * timeline shows, it reads the clock each second itself, so live and the
+ * track's end keep up with now.
+ */
+export const useWorldTimeStore = defineStore('gev-world-time', () => {
+ const spanOf = (state) => {
+ if (!state.supported) return null;
+ 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),
+ marks: coverage.marks,
+ });
+ };
+ const read = (state = worldClock.getState()) =>
+ Object.freeze({
+ live: state.live,
+ playing: state.playing,
+ timeMs: state.timeMs,
+ supported: state.supported,
+ span: spanOf(state),
+ });
+ const time = shallowRef(read());
+ let ticker = null;
+ const syncTicker = () => {
+ // Playing, the clock tells of the time itself.
+ const ticking = Boolean(time.value.span) && !time.value.playing;
+ if (ticking && ticker === null)
+ ticker = setInterval(() => {
+ time.value = read();
+ }, LIVE_TICK_MS);
+ else if (!ticking && ticker !== null) {
+ clearInterval(ticker);
+ ticker = null;
+ }
+ };
+ const unsubscribe = worldClock.subscribe((state) => {
+ time.value = read(state);
+ syncTicker();
+ });
+ syncTicker();
+ onScopeDispose(() => {
+ unsubscribe();
+ if (ticker !== null) clearInterval(ticker);
+ ticker = null;
+ });
+
+ return {
+ time,
+ togglePlay: () => worldClock.togglePlay(),
+ 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)) {
+ worldClock.goLive();
+ return;
+ }
+ worldClock.pause();
+ worldClock.seek(ms);
+ },
+ };
+});
diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css
index d7ad845b6..ea422643a 100644
--- a/src/ui/styles/cockpit.css
+++ b/src/ui/styles/cockpit.css
@@ -900,7 +900,7 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
position: fixed;
z-index: 146;
right: 36px;
- bottom: calc(2vh + 4.5rem);
+ bottom: calc(2vh + 4.5rem + var(--world-timeline-lift, 0px));
display: grid;
gap: 6px;
}
@@ -2079,7 +2079,7 @@ body.cockpit-mode #intel-hud .hud-right-edge {
@media (max-width: 760px) {
#view-switcher {
right: 16px;
- bottom: 16px;
+ bottom: calc(16px + var(--world-timeline-lift, 0px));
}
body.cockpit-mode #view-switcher {
right: auto;
diff --git a/src/ui/styles/command-dock-compact.css b/src/ui/styles/command-dock-compact.css
index ced41df8f..0c40799de 100644
--- a/src/ui/styles/command-dock-compact.css
+++ b/src/ui/styles/command-dock-compact.css
@@ -377,15 +377,15 @@
/* Compact dock clearance keeps the reference-like low profile. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
- bottom: calc(2vh + 4.7rem);
+ bottom: calc(2vh + 4.7rem + var(--world-timeline-lift, 0px));
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
- bottom: calc(2vh + 5.4rem);
+ bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px));
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
- bottom: calc(2vh + 8.9rem);
+ bottom: calc(2vh + 8.9rem + var(--world-timeline-lift, 0px));
}
@media (max-width: 900px) {
diff --git a/src/ui/styles/command-dock-trays.css b/src/ui/styles/command-dock-trays.css
index fb41acb8e..08580ecb2 100644
--- a/src/ui/styles/command-dock-trays.css
+++ b/src/ui/styles/command-dock-trays.css
@@ -455,7 +455,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
)
#cesium-credits {
right: auto;
- bottom: calc(2vh + 5rem);
+ bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
left: 1.5rem;
width: auto !important;
max-width: calc(100vw - 3rem);
@@ -464,21 +464,21 @@ body:not(.ui-clean-view):not(.recording-mode):has(
#intel-hud[data-variant='tactical'] .hud-bottom-left,
#intel-hud[data-variant='tactical'] .hud-bottom-right {
- bottom: calc(2vh + 6.5rem);
+ bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
- bottom: calc(2vh + 5.4rem);
+ bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px));
}
:root[data-scope-mask='off']
#intel-hud[data-variant='minimal']
.hud-bottom-left {
- bottom: calc(2vh + 6.5rem);
+ bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
- bottom: calc(2vh + 9rem);
+ bottom: calc(2vh + 9rem + var(--world-timeline-lift, 0px));
}
@media (max-width: 900px) {
@@ -487,7 +487,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
#intel-hud[data-variant='minimal'].active
)
#cesium-credits {
- bottom: calc(2vh + 5rem);
+ bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
left: 0.75rem;
max-width: calc(100vw - 1.5rem);
}
diff --git a/src/ui/styles/command-dock.css b/src/ui/styles/command-dock.css
index 3c6c8e531..808b5f480 100644
--- a/src/ui/styles/command-dock.css
+++ b/src/ui/styles/command-dock.css
@@ -2,7 +2,8 @@
#command-dock {
position: fixed;
right: auto;
- bottom: 2vh;
+ /* Above the world timeline while it shows (world-timeline.css). */
+ bottom: calc(2vh + var(--world-timeline-lift, 0px));
left: 50%;
z-index: 145;
display: grid;
@@ -216,7 +217,7 @@
/* Keep required map attribution clear of the full-width command surface. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
- bottom: calc(2vh + 7rem);
+ bottom: calc(2vh + 7rem + var(--world-timeline-lift, 0px));
}
body:not(.ui-clean-view):not(.recording-mode):has(
@@ -244,11 +245,11 @@ body.recording-mode #cesium-credits {
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
- bottom: calc(2vh + 7.5rem);
+ bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px));
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
- bottom: calc(2vh + 11.5rem);
+ bottom: calc(2vh + 11.5rem + var(--world-timeline-lift, 0px));
}
@media (max-width: 980px) {
@@ -275,21 +276,21 @@ body.recording-mode #cesium-credits {
}
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
- bottom: calc(2vh + 9.5rem);
+ bottom: calc(2vh + 9.5rem + var(--world-timeline-lift, 0px));
}
#intel-hud[data-variant='minimal'] .hud-bottom-left {
- bottom: calc(2vh + 12rem);
+ bottom: calc(2vh + 12rem + var(--world-timeline-lift, 0px));
}
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
- bottom: calc(2vh + 16rem);
+ bottom: calc(2vh + 16rem + var(--world-timeline-lift, 0px));
}
}
@media (max-width: 720px) {
#command-dock {
- bottom: 8px;
+ bottom: calc(8px + var(--world-timeline-lift, 0px));
}
#command-dock .location-search-wrap {
diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css
index 4aaf5b1c2..9c80ddf4a 100644
--- a/src/ui/styles/cyber.css
+++ b/src/ui/styles/cyber.css
@@ -1590,7 +1590,7 @@
}
#intel-hud[data-variant='cyber'] .hud-bottom-bar {
- bottom: calc(2vh + 9.8rem);
+ bottom: calc(2vh + 9.8rem + var(--world-timeline-lift, 0px));
width: min(48vw, 680px);
}
@@ -1601,13 +1601,13 @@
#intel-hud[data-variant='cyber'] .hud-bottom-left,
#intel-hud[data-variant='cyber'] .hud-bottom-right {
- bottom: calc(2vh + 6.5rem);
+ bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
}
/* Attribution's logo row extends above its text baseline. Reserve the whole
row plus a gap above the dock-relative 2vh + 5rem credit position. */
#intel-hud[data-variant='cyber'] .hud-bottom-left {
- bottom: calc(2vh + 7.75rem);
+ bottom: calc(2vh + 7.75rem + var(--world-timeline-lift, 0px));
}
#intel-hud[data-variant='cyber'] .hud-corner {
@@ -1652,7 +1652,7 @@
below its 650px threshold. Keep the left rail inside the same telemetry
and attribution-safe floor while its panel bodies take over scrolling. */
:root[data-ui-theme='cyber'] #left-panel-stack {
- bottom: calc(2vh + 13rem);
+ bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
}
}
@@ -1675,7 +1675,7 @@
/* The stack holds still, so its rail needs no offset (leftPanelRail.js). */
--panel-stack-top-pinned: 1;
top: var(--cyber-left-rail-top) !important;
- bottom: calc(2vh + 13rem);
+ bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
inset-inline-start: calc(54px + var(--panel-rail-clearance));
width: 300px;
padding-inline-start: 10px;
diff --git a/src/ui/styles/mobile-shell.css b/src/ui/styles/mobile-shell.css
index 707b0f43f..b607391de 100644
--- a/src/ui/styles/mobile-shell.css
+++ b/src/ui/styles/mobile-shell.css
@@ -200,7 +200,7 @@ body.mobile-shell #command-dock {
/* The dock's tray rules size voice by this basis; a column makes it a height. */
--dock-voice-width: auto;
top: auto;
- bottom: calc(var(--mobile-tab-space) + 10px);
+ bottom: calc(var(--mobile-tab-space) + 10px + var(--world-timeline-lift, 0px));
display: flex;
flex-direction: column;
align-items: center;
@@ -380,7 +380,7 @@ body.mobile-shell #command-dock #gev-voice-control {
body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
#cesium-credits {
right: auto;
- bottom: calc(var(--mobile-tab-space) + 106px) !important;
+ bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px)) !important;
left: 10px;
/* With the base rule's nowrap the credits run off the right edge, and a
max-width alone wrapped them into a narrow column: the line needs wrapping
@@ -407,7 +407,7 @@ body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(
/* The view switcher clears the tab bar and the voice bar, opposite the credit. */
body.mobile-shell:not(.cockpit-mode) #view-switcher {
right: 12px;
- bottom: calc(var(--mobile-tab-space) + 106px);
+ bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px));
}
body.mobile-shell #corner-actions {
diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css
index 27a8ded3c..d3d986f44 100644
--- a/src/ui/styles/responsive.css
+++ b/src/ui/styles/responsive.css
@@ -177,7 +177,7 @@
drawn straight over the notice. Track the credit's own anchor instead
and the 12px of clear air holds regardless of height; the rail already
scrolls (overflow-y below), so it loses reach, not content. */
- bottom: calc(2vh + 7.5rem);
+ bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px));
left: 16px;
width: auto;
max-height: none;
diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css
index bba1ef788..404b9cd2c 100644
--- a/src/ui/styles/status.css
+++ b/src/ui/styles/status.css
@@ -390,7 +390,7 @@ html:not([lang='en']) #global-loading-detail {
/* ── Toast Notification ──────────────────────── */
#toast {
position: fixed;
- bottom: 100px;
+ bottom: calc(100px + var(--world-timeline-lift, 0px));
left: 50%;
transform: translateX(-50%) translateY(20px);
z-index: 200;
diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css
new file mode 100644
index 000000000..e7560a467
--- /dev/null
+++ b/src/ui/styles/world-timeline.css
@@ -0,0 +1,222 @@
+/* ── World timeline ─────────────────────────── */
+/* Shown while a layer with time to show is on, centred along the bottom of
+ the screen; the command dock and what sits above it rise by its height. */
+:root {
+ --world-timeline-lift: 0px;
+}
+
+body:has(#world-timeline) {
+ --world-timeline-lift: 50px;
+}
+
+/* Clean view, recording and the cockpit put the dock away; the bar goes too. */
+body.ui-clean-view,
+body.recording-mode,
+body.cockpit-mode {
+ --world-timeline-lift: 0px !important;
+}
+
+body.ui-clean-view #world-timeline,
+body.recording-mode #world-timeline,
+body.cockpit-mode #world-timeline {
+ display: none;
+}
+
+#world-timeline {
+ position: fixed;
+ bottom: 2vh;
+ left: 50%;
+ z-index: 146;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: min(820px, calc(100vw - 24px));
+ height: 42px;
+ box-sizing: border-box;
+ padding: 4px 6px;
+ border: 1px solid var(--glass-border);
+ border-radius: 10px;
+ background: var(--glass-bg);
+ backdrop-filter: blur(24px) saturate(1.4);
+ -webkit-backdrop-filter: blur(24px) saturate(1.4);
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
+ transform: translateX(-50%);
+}
+
+#world-timeline .world-timeline-play,
+#world-timeline .world-timeline-live {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 32px;
+ height: 32px;
+ padding: 0 8px;
+ border: 1px solid rgba(123, 189, 211, 0.28);
+ border-radius: 8px;
+ background: rgba(0, 212, 255, 0.08);
+ color: var(--text-primary);
+ font: inherit;
+ cursor: pointer;
+}
+
+#world-timeline .world-timeline-play:hover,
+#world-timeline .world-timeline-live:hover {
+ background: rgba(0, 212, 255, 0.18);
+}
+
+#world-timeline .world-timeline-live.is-live {
+ border-color: rgba(0, 212, 255, 0.6);
+ background: rgba(0, 212, 255, 0.2);
+ color: var(--accent);
+ cursor: default;
+}
+
+#world-timeline .world-timeline-live {
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+#world-timeline .world-timeline-time {
+ overflow: hidden;
+ font-family: var(--font-mono);
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#world-timeline .world-timeline-edge {
+ flex: none;
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+/* The track: ticks for each launch or frame, the elapsed fill, and a native
+ range input on top for dragging and the keyboard. */
+#world-timeline .world-timeline-track {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 80px;
+ height: 24px;
+}
+
+#world-timeline .world-timeline-track::before {
+ position: absolute;
+ top: 50%;
+ right: 0;
+ left: 0;
+ height: 4px;
+ border-radius: 2px;
+ background: rgba(123, 189, 211, 0.22);
+ content: '';
+ transform: translateY(-50%);
+}
+
+#world-timeline .world-timeline-fill {
+ position: absolute;
+ top: 50%;
+ left: 0;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--accent);
+ opacity: 0.7;
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
+#world-timeline .world-timeline-tick {
+ position: absolute;
+ top: 50%;
+ width: 2px;
+ height: 12px;
+ margin-left: -1px;
+ border-radius: 1px;
+ background: rgba(0, 212, 255, 0.75);
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
+#world-timeline .world-timeline-scrub {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ background: transparent;
+ cursor: pointer;
+ appearance: none;
+}
+
+#world-timeline .world-timeline-scrub::-webkit-slider-runnable-track {
+ height: 100%;
+ background: transparent;
+}
+
+#world-timeline .world-timeline-scrub::-moz-range-track {
+ background: transparent;
+}
+
+#world-timeline .world-timeline-scrub::-webkit-slider-thumb {
+ width: 14px;
+ height: 14px;
+ margin-top: 5px;
+ border: 2px solid var(--accent);
+ border-radius: 50%;
+ background: #031018;
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.6);
+ appearance: none;
+}
+
+#world-timeline .world-timeline-scrub::-moz-range-thumb {
+ width: 12px;
+ height: 12px;
+ border: 2px solid var(--accent);
+ border-radius: 50%;
+ background: #031018;
+}
+
+#world-timeline .world-timeline-scrub:focus-visible {
+ outline: 1px solid var(--accent);
+ outline-offset: 2px;
+}
+
+@media (max-width: 720px) {
+ #world-timeline {
+ bottom: 8px;
+ }
+}
+
+/* On phones the bar sits just above the tab bar, under the voice dock, with
+ the track on a row of its own beneath play, the time and live. */
+body.mobile-shell:has(#world-timeline) {
+ --world-timeline-lift: 80px;
+}
+
+body.mobile-shell #world-timeline {
+ bottom: calc(var(--mobile-tab-space) + 6px);
+ flex-wrap: wrap;
+ row-gap: 4px;
+ height: auto;
+}
+
+body.mobile-shell #world-timeline .world-timeline-time {
+ flex: 1 1 0;
+}
+
+body.mobile-shell #world-timeline .world-timeline-track {
+ order: 1;
+ flex-basis: 100%;
+}
+
+@media (max-width: 640px) {
+ #world-timeline .world-timeline-edge {
+ display: none;
+ }
+
+ #world-timeline .world-timeline-time {
+ font-size: 11px;
+ }
+}
diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue
index f77dad3d2..32900f42a 100644
--- a/src/ui/views/SceneChromeView.vue
+++ b/src/ui/views/SceneChromeView.vue
@@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue';
import AwarenessDirection from './panels/AwarenessDirection.vue';
import UiButton from '../kit/UiButton.vue';
import GlobeActions from './panels/GlobeActions.vue';
+import WorldTimeline from './panels/WorldTimeline.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
const { style } = storeToRefs(useDisplayStore());
@@ -117,4 +118,5 @@ const vToastText = {
+
diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs
new file mode 100644
index 000000000..60a73540b
--- /dev/null
+++ b/src/ui/views/panels/WorldTimeline.test.mjs
@@ -0,0 +1,150 @@
+// The world timeline shows the world time while a layer with time to show is
+// on, with play and pause on the left and the way back to live on the right.
+import '../../../testSupport/registerDom.mjs';
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createApp, nextTick } from 'vue';
+import { createPinia } from 'pinia';
+import { worldClock } from '../../../time/worldClock.js';
+import WorldTimeline from './WorldTimeline.vue';
+import { useWorldTimeStore } from '../../stores/worldTime.js';
+
+function mount(t) {
+ document.body.replaceChildren();
+ const pinia = createPinia();
+ const app = createApp(WorldTimeline).use(pinia);
+ app.mount(document.body.appendChild(document.createElement('div')));
+ t.after(() => {
+ app.unmount();
+ // The store outlives the view; dispose it with its clock subscription.
+ useWorldTimeStore(pinia).$dispose();
+ worldClock.goLive();
+ });
+ return () => document.getElementById('world-timeline');
+}
+
+// A layer that moves with the clock and has an hour of frames.
+function supportingLayer(t, at) {
+ const release = worldClock.hold('test-radar');
+ const unregister = worldClock.register({
+ id: 'test-radar',
+ coverage: () => ({ start: at - 3_600_000, end: at }),
+ });
+ t.after(() => {
+ unregister();
+ release();
+ });
+ return () => {
+ unregister();
+ release();
+ };
+}
+
+test('the timeline shows live, and plays, pauses and goes back', async (t) => {
+ const bar = mount(t);
+ const at = Date.UTC(2026, 9, 30, 6, 15, 42);
+ supportingLayer(t, at);
+ await nextTick();
+ assert.ok(bar(), 'shown while live');
+ const live = bar().querySelector('.world-timeline-live');
+ assert.equal(live.disabled, true, 'already live');
+ assert.equal(live.getAttribute('aria-pressed'), 'true');
+
+ worldClock.seek(at);
+ await nextTick();
+ assert.ok(bar());
+ const time = bar().querySelector('time');
+ assert.equal(time.getAttribute('datetime'), new Date(at).toISOString());
+ assert.match(time.textContent, /UTC$/);
+ const play = bar().querySelector('.world-timeline-play');
+ assert.equal(play.getAttribute('aria-label'), 'Play');
+
+ play.click();
+ await nextTick();
+ assert.equal(worldClock.isPlaying(), true);
+ assert.equal(play.getAttribute('aria-label'), 'Pause');
+ play.click();
+ await nextTick();
+ assert.equal(worldClock.isPlaying(), false);
+
+ assert.equal(bar().querySelector('.world-timeline-live').disabled, false);
+ bar().querySelector('.world-timeline-live').click();
+ await nextTick();
+ assert.equal(worldClock.isLive(), true);
+ assert.ok(bar(), 'still shown, live');
+ assert.equal(bar().querySelector('.world-timeline-live').disabled, true);
+});
+
+test('the timeline hides while no layer with time to show is on', async (t) => {
+ const bar = mount(t);
+ const at = Date.UTC(2026, 9, 30, 6, 15, 42);
+ await nextTick();
+ assert.equal(bar(), null, 'live, nothing to play');
+ worldClock.seek(at);
+ await nextTick();
+ assert.equal(bar(), null, 'away from live, but nothing to play');
+ const remove = supportingLayer(t, at);
+ await nextTick();
+ assert.ok(bar());
+ remove();
+ await nextTick();
+ assert.equal(bar(), null, 'the layer went off');
+});
+
+test('the track runs over the time the layers show, to now, and scrubs', async (t) => {
+ const bar = mount(t);
+ const now = Date.now();
+ const at = now - 2 * 86_400_000;
+ const release = worldClock.hold('test-launches');
+ const unregister = worldClock.register({
+ id: 'test-launches',
+ coverage: () => ({ start: at - 86_400_000, end: at, marks: [at - 86_400_000, at] }),
+ });
+ t.after(() => {
+ unregister();
+ release();
+ });
+ await nextTick();
+ const input = bar().querySelector('input[type=range]');
+ assert.equal(Number(input.min), at - 86_400_000);
+ assert.ok(Number(input.max) >= now, 'reaches to now');
+ assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 2);
+
+ worldClock.play();
+ input.value = String(at);
+ input.dispatchEvent(new Event('input'));
+ await nextTick();
+ assert.equal(worldClock.isPlaying(), false, 'a scrub stops playback');
+ assert.equal(worldClock.now(), at);
+ assert.equal(bar().querySelector('time').getAttribute('datetime'), new Date(at).toISOString());
+
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
+ await nextTick();
+ assert.equal(worldClock.now(), at - 86_400_000, 'the arrow goes to the previous mark');
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
+ await nextTick();
+ assert.equal(worldClock.now(), at, 'and back to the next');
+
+ input.value = String(Date.now() + 2_000);
+ input.dispatchEvent(new Event('input'));
+ await nextTick();
+ assert.equal(worldClock.isLive(), true, 'a scrub to now goes live');
+
+ // A layer that reaches ahead of now, as satellites do.
+ const holdAhead = worldClock.hold('test-ahead');
+ const unregisterAhead = worldClock.register({
+ id: 'test-ahead',
+ coverage: () => ({ start: now, end: now + 3 * 86_400_000 }),
+ });
+ t.after(() => {
+ unregisterAhead();
+ holdAhead();
+ });
+ await nextTick();
+ const ahead = Date.now() + 86_400_000;
+ input.value = String(ahead);
+ input.dispatchEvent(new Event('input'));
+ await nextTick();
+ assert.equal(worldClock.isLive(), false, 'a scrub past now stays there');
+ assert.equal(worldClock.now(), ahead);
+});
diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue
new file mode 100644
index 000000000..1c4a8bdc0
--- /dev/null
+++ b/src/ui/views/panels/WorldTimeline.vue
@@ -0,0 +1,95 @@
+
+
+
+