Merge pull request #1512 from bilawalsidhu/sameh/world-clock

Keep one world clock, with a timeline to move it
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-08 12:23:12 -08:00
commit 424bcb0843
86 files changed
+2088 -145

No files matched your search

+5
View File
@@ -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).
+5
View File
@@ -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).
+1 -1
View File
@@ -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. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+1
View File
@@ -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"
},
+34 -8
View File
@@ -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": []
},
+2 -1
View File
@@ -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();
+5 -1
View File
@@ -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,
});
}
+4
View File
@@ -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()),
});
}
+8
View File
@@ -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);
+14 -1
View File
@@ -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()) {
+41 -17
View File
@@ -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',
);
});
+14
View File
@@ -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']);
});
+9 -4
View File
@@ -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;
+4 -1
View File
@@ -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": "مباشر"
}
+4 -1
View File
@@ -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"
}
+4 -1
View File
@@ -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"
}
+4 -1
View File
@@ -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"
}
+4 -1
View File
@@ -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"
}
+4 -1
View File
@@ -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": "ライブ"
}
+4 -1
View File
@@ -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"
}
+4 -1
View File
@@ -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": "Сейчас"
}
+4 -1
View File
@@ -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": "Зараз"
}
+4 -1
View File
@@ -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": "实时"
}
+4 -1
View File
@@ -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": "即時"
}
+1
View File
@@ -98,6 +98,7 @@
"Landsat 8/9",
"Latin",
"Layout",
"Live",
"London",
"Los Angeles",
"MIL",
+3 -2
View File
@@ -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,
);
+8 -6
View File
@@ -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);
});
+2 -1
View File
@@ -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,
);
+3
View File
@@ -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
+2 -1
View File
@@ -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,
);
+3 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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 || []) {
+1
View File
@@ -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) {
+1 -1
View File
@@ -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,
);
+18
View File
@@ -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) {
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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 =
+13
View File
@@ -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();
+3 -2
View File
@@ -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,
);
+3
View File
@@ -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
+2 -1
View File
@@ -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,
);
+4 -2
View File
@@ -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,
+4 -1
View File
@@ -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 {
+2 -1
View File
@@ -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');
+2
View File
@@ -33,6 +33,8 @@ function ingestionFor(catalogText) {
_denseLoadToken: 0,
_catalogRevision: 0,
_params: { catalog: 'core', showOrbits: false },
now: Date.now,
coverageChanged() {},
};
const parts = {
orbits: {
+20 -1
View File
@@ -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();
},
};
+3 -3
View File
@@ -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 {
+9
View File
@@ -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;
+1 -1
View File
@@ -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;
+19 -5
View File
@@ -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;
}
}
+9
View File
@@ -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();
+5 -2
View File
@@ -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
+6 -2
View File
@@ -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<import('./registry.js').StreetLevelProvider>, 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();
+1
View File
@@ -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({
+6 -6
View File
@@ -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);
+3
View File
@@ -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,
+128 -17
View File
@@ -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;
}
+218
View File
@@ -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);
});
+2 -1
View File
@@ -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,
+14 -2
View File
@@ -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(
+21
View File
@@ -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,
);
});
+11 -2
View File
@@ -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;
+25
View File
@@ -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();
});
+13
View File
@@ -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);
}
+46
View File
@@ -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();
};
}
+349
View File
@@ -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<string>}} [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);
+307
View File
@@ -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);
});
+4 -4
View File
@@ -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/);
+1 -1
View File
@@ -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(
+2
View File
@@ -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({
+12
View File
@@ -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;
+83
View File
@@ -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);
},
};
});
+2 -2
View File
@@ -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;
+3 -3
View File
@@ -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) {
+6 -6
View File
@@ -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);
}
+9 -8
View File
@@ -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 {
+5 -5
View File
@@ -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;
+3 -3
View File
@@ -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 {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+222
View File
@@ -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;
}
}
+2
View File
@@ -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 = {
<MissionReplayOverlay />
<ScreenAnnotations />
<AwarenessDirection />
<WorldTimeline />
</template>
+150
View File
@@ -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);
});
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { formatDate, t } from '../../../i18n/index.js';
import { useWorldTimeStore } from '../../stores/worldTime.js';
import UiButton from '../../kit/UiButton.vue';
import UiIcon from '../../kit/UiIcon.vue';
// The world timeline: play and pause on the left, the time, a track across
// the stretch the layers on can show (each launch or frame a tick) with a
// handle to scrub, and the way back to live on the right, lit while live. It
// shows while a layer with a time of its own (weather, satellites, launches,
// or one a host follows) is on with time to show; with only live feeds such
// as flights on, the bar stays hidden.
const store = useWorldTimeStore();
const { time } = storeToRefs(store);
const DATE_TIME = Object.freeze({
dateStyle: 'medium',
timeStyle: 'medium',
timeZone: 'UTC',
});
const EDGE_DATE = Object.freeze({
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
// The keyboard's fallback step, as a share of the track, with no mark to go to.
const KEY_STEP = 0.01;
const PAGE_STEP = 0.1;
const readout = computed(() =>
Number.isFinite(time.value.timeMs)
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC`
: '',
);
const span = computed(() => time.value.span);
const at = computed(() => {
const { start, end } = span.value;
return Math.min(end, Math.max(start, time.value.timeMs));
});
const place = (ms) => {
const { start, end } = span.value;
return end > start ? ((ms - start) / (end - start)) * 100 : 100;
};
const ticks = computed(() =>
span.value.marks.map((mark) => `${place(mark)}%`),
);
const scrub = (event) => store.scrub(Number(event.target.value));
// Arrows go to the previous or next mark (a launch, a frame), or a step along
// the track; Page keys take a tenth of it; Home and End go to its ends.
const onKey = (event) => {
const { start, end, marks } = span.value;
const now = at.value;
const length = end - start;
let to = null;
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown')
to = marks.findLast((mark) => mark < now) ?? now - length * KEY_STEP;
else if (event.key === 'ArrowRight' || event.key === 'ArrowUp')
to = marks.find((mark) => mark > now) ?? now + length * KEY_STEP;
else if (event.key === 'PageDown') to = now - length * PAGE_STEP;
else if (event.key === 'PageUp') to = now + length * PAGE_STEP;
else if (event.key === 'Home') to = start;
else if (event.key === 'End') to = end;
if (to === null) return;
event.preventDefault();
store.scrub(Math.min(end, Math.max(start, to)));
};
</script>
<template>
<div v-if="time.supported && time.span" id="world-timeline" role="group" :aria-label="t('World time')"
><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()"
><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton
><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
><span class="world-timeline-edge">{{ formatDate(span.start, EDGE_DATE) }}</span
><div class="world-timeline-track"
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
><span class="world-timeline-fill" :style="{ width: `${place(at)}%` }" aria-hidden="true"></span
><input
class="world-timeline-scrub"
type="range"
:aria-label="t('World time')"
:aria-valuetext="readout"
:min="span.start"
:max="span.end"
step="any"
:value="at"
@input="scrub"
@keydown="onKey"
/></div
><span class="world-timeline-edge">{{ formatDate(span.end, EDGE_DATE) }}</span
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
></div>
</template>
+4 -2
View File
@@ -74,9 +74,11 @@ test('bikeshare stations join coordinates from station information', () => {
assert.deepEqual(methods.getAnalystRecords(), []);
});
test('launches carry time and hours until launch', () => {
const at = Date.now() + 5 * 3_600_000;
test('launches carry time and hours until launch at the world time', () => {
const now = Date.UTC(2026, 10, 1);
const at = now + 5 * 3_600_000;
const state = {
now: () => now,
_enabled: true,
_launches: [
{
+1
View File
@@ -30,6 +30,7 @@
@import './src/ui/styles/provider-settings.css';
@import './src/ui/styles/locale-picker.css';
@import './src/ui/styles/weather.css';
@import './src/ui/styles/world-timeline.css';
@import './src/ui/styles/panel-surfaces.css';
@import './src/ui/styles/segmented-control.css';
@import './src/ui/styles/kit.css';