Compare commits

...
Author SHA1 Message Date
Sameh Khamis 9ab54675ba Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	scripts/package-boundaries.json
2026-11-02 17:01:02 -08:00
Sameh Khamis 09d833ed96 Add the changelog entry for the world clock and timeline 2026-11-02 13:17:06 -08:00
Sameh Khamis 32e08f5043 Merge remote-tracking branch 'origin/main' into sameh/world-clock
# Conflicts:
#	index.html
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-02 13:14:32 -08:00
Sameh Khamis 48093108b1 Make the world timeline a scrubber under the dock
The timeline shows whenever a layer with time to show is on, live or not.
Its track runs over the time those layers cover, reaching to now, with a
tick for each launch or frame and a handle to drag; arrows step between the
ticks, and a scrub to now goes live. The bar sits along the bottom of the
screen, and the dock and everything anchored above it rise by its height;
clean view, recording and the cockpit put it away with the dock.
2026-11-01 23:37:12 -08:00
Sameh Khamis 9faec3028e Show the world time on the HUD clock
The HUD's UTC and collection readouts follow the world clock with the sky,
so away from live they read the time the globe is drawn at.
2026-11-01 23:17:53 -08:00
Sameh Khamis 3534398771 Move satellites, launches and the sky with the world time
Layers whose data has a time of its own follow the page's world clock from
the start; live feeds such as flights, military, vessels and transit stay
live. Launches register the span of the launches loaded, so turning them on
brings up the timeline. Each timed layer registers its coverage once and
reports none while off, telling the clock after each step has settled. The
launch feed keeps its own window, and an orbit ring is drawn again once the
world time has moved far from when it was drawn.
2026-11-01 22:53:51 -08:00
Sameh Khamis 690a6637e2 Show the timeline only while a layer with time to show is on
The bar now needs more than the world clock being away from live: a layer
that moves with the clock must be on with time to show. In Core that is
weather radar, satellite or lightning with frames; a host adds layers by
following them. Turning the last one off hides the bar; turning it back
on brings it back.

The world clock reports this as `supported` in its state. Weather products
register their frames as coverage one by one, suspended or not, so the way
back to live stays on screen with reduced motion. Weather refreshes and
satellite catalog changes tell the clock their coverage may have changed.
A coverage that throws counts as none, and the clock reads its state once
per notification round.
2026-11-01 22:36:45 -08:00
Sameh Khamis 434fc9602d Show a basic timeline while the world time is away from live
A bar above the command dock, like a video player's: play and pause on
the left, the world time's date and time in UTC beside it, and Live to
return. It stays hidden while live, so Core looks as before until weather
history is scrubbed or replayed, a host moves the clock, or a link made
away from live is opened. Adds the pause and play_arrow glyphs and the
strings in every catalog.
2026-11-01 21:55:33 -08:00
Sameh Khamis 32ec6ed436 Share links carry the world time when made away from live
Field 28, worldTime, holds the world time of a link made away from live:
zigzag seconds since 2026-01-01, like field 8, but it may lie ahead of the
link's creation (a forecast moment). Opening such a link pauses the world
clock at that time; a link without it leaves the clock alone, so a page
opens live and Back or Forward does not stop a replay. Times no JS Date
can hold are dropped.
2026-11-01 21:50:09 -08:00
Sameh Khamis 0c4da3cdb2 Keep one world clock that every timed layer reads
Core told time eight ways: Date.now() in most layers, JulianDate.now() for
flights and military, Cesium's clock frozen at page load for the sky, the
weather clock for observed imagery. They now read one clock,
src/time/worldClock.js, exported as ./time.

Live is a mode, not a timestamp: while live the clock is the wall clock,
so every layer draws as before. Away from live only the layers that follow
the clock move with it. The weather products follow it, as they followed
the weather transport; a host opts others in with follow(id).

- The weather clock takes its time from the world clock and drives its
  replay, stepping its frames as before.
- Satellites, launches, flights and military (still 30 s and 15 s behind),
  transit, GNSS, Recent Imagery, street level, the HUD and the celestial
  ring read worldTimeFor(id). Cadences stay on the wall clock.
- Cesium's sky (stars, sun, moon, atmosphere, lighting) draws at the world
  time each frame instead of the moment the page loaded.
- Weather frames and satellites declare coverage() for a host's timeline.
2026-11-01 21:47:32 -08:00
86 changed files with 2091 additions and 148 deletions

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 | | 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | | 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | | 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, 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. 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 `hudVariant` code 1 (`operator`) is retired: the app no longer offers that
layout and restores it as `tactical`. The code stays reserved. 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 Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). `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 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 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. --> 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,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,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,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" 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,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,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,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
</head> </head>
<body> <body>
<div class="gev-view" data-gev-view="scene-chrome"></div> <div class="gev-view" data-gev-view="scene-chrome"></div>
+1
View File
@@ -89,6 +89,7 @@
"./infrastructure/lod": "./src/data/localGeojsonLod.js", "./infrastructure/lod": "./src/data/localGeojsonLod.js",
"./application": "./src/app/application.js", "./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js", "./application/viewer": "./src/app/viewer.js",
"./time": "./src/time/worldClock.js",
"./build/vite": { "./build/vite": {
"node": "./build/vite.js" "node": "./build/vite.js"
}, },
+37 -11
View File
@@ -149,10 +149,19 @@
"src/ui/views/panels/GlobeCompass.vue", "src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
"src/ui/i18n.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"
], ],
"external": ["vue", "pinia", "vue-i18n"] "external": ["vue", "pinia", "vue-i18n"]
}, },
"time": {
"exports": ["./time"],
"modules": ["src/time/worldClock.js"],
"external": []
},
"viewer": { "viewer": {
"exports": ["./application/viewer"], "exports": ["./application/viewer"],
"modules": [ "modules": [
@@ -1123,7 +1132,8 @@
"src/ui/kit/UiBadge.vue", "src/ui/kit/UiBadge.vue",
"src/ui/stores/globeActions.js", "src/ui/stores/globeActions.js",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/i18n.js" "src/ui/i18n.js",
"src/time/worldClock.js"
], ],
"external": ["cesium", "vue", "pinia", "vue-i18n"] "external": ["cesium", "vue", "pinia", "vue-i18n"]
}, },
@@ -1334,7 +1344,9 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.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"] "external": ["cesium"]
}, },
@@ -1390,7 +1402,9 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.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"] "external": ["cesium"]
}, },
@@ -1575,7 +1589,8 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js",
"src/time/worldClock.js"
], ],
"external": ["cesium", "satellite.js"] "external": ["cesium", "satellite.js"]
}, },
@@ -1615,7 +1630,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.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"] "external": ["cesium"]
}, },
@@ -1935,7 +1951,8 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.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"] "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
}, },
@@ -3175,6 +3192,9 @@
"src/ui/stores/globeActions.js", "src/ui/stores/globeActions.js",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/i18n.js", "src/ui/i18n.js",
"src/time/viewerClock.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js", "src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js", "src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js", "src/sources/nominatimOutlines.js",
@@ -3782,6 +3802,8 @@
"src/ui/stores/bhoteKoshiPanel.js", "src/ui/stores/bhoteKoshiPanel.js",
"src/hudReadouts.js", "src/hudReadouts.js",
"src/ui/i18n.js", "src/ui/i18n.js",
"src/time/worldClock.js",
"src/time/julian.js",
"src/annotations/outlineRungs.js", "src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js", "src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js", "src/sources/nominatimOutlines.js",
@@ -3941,7 +3963,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json",
"src/time/worldClock.js"
], ],
"external": [] "external": []
}, },
@@ -4460,7 +4483,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.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", "egm96-universal", "pbf"] "external": ["cesium", "egm96-universal", "pbf"]
}, },
@@ -4512,7 +4536,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.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"] "external": ["cesium", "pbf"]
}, },
@@ -4632,7 +4657,8 @@
"src/data/detectionPolicy.js", "src/data/detectionPolicy.js",
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/visualStylePolicy.js" "src/visualStylePolicy.js",
"src/time/worldClock.js"
], ],
"external": [] "external": []
}, },
+2 -1
View File
@@ -1,5 +1,6 @@
import { resolveCatalogSources } from './sourceComposition.js'; import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherClock } from '../layers/weather/clock.js';
import { worldClock } from '../time/worldClock.js';
import { createWeatherLayer } from '../layers/weather/index.js'; import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js'; import { createWindLayer } from '../layers/wind/index.js';
@@ -72,7 +73,7 @@ export function createApplicationCatalog({
const sourceComposition = resolveCatalogSources(sources); const sourceComposition = resolveCatalogSources(sources);
sources = sourceComposition.sources; sources = sourceComposition.sources;
const militaryRegistry = createMilitaryRegistry(); const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock(); const weatherClock = createWeatherClock({ world: worldClock });
const dispose = () => { const dispose = () => {
signal.removeEventListener('abort', dispose); signal.removeEventListener('abort', dispose);
militaryRegistry.dispose(); militaryRegistry.dispose();
+5 -1
View File
@@ -1,6 +1,10 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; 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. */ /** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) { 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 { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.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. */ /** Construct imagery presentation over one explicitly supplied acquisition source. */
export function createApplicationRecentImagery({ source }) { export function createApplicationRecentImagery({ source }) {
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
thumbnails: createThumbnailLoader({ thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args), requestThumbnail: (...args) => source.getThumbnail(...args),
}), }),
now: () => new Date(imageryTime()),
}); });
} }
+8
View File
@@ -14,6 +14,8 @@ import {
governorRequestRender, governorRequestRender,
} from '../renderGovernor.js'; } from '../renderGovernor.js';
import { describeError } from './errors.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'; import { onLocaleChange, t } from '../i18n/index.js';
/** Construct the application globe using the caller's local configuration. */ /** Construct the application globe using the caller's local configuration. */
@@ -58,6 +60,12 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
}); });
defer(installTrackpadPinchZoom(viewer)); defer(installTrackpadPinchZoom(viewer));
defer(
followWorldTime(viewer, worldTimeFor('sky'), {
subscribe: worldClock.subscribe,
requestRender: () => governorRequestRender('world-time'),
}),
);
registerDataCredits(viewer, credits); registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+14 -1
View File
@@ -1,5 +1,9 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { governorRequestRender } from './renderGovernor.js'; 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. */ /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
export const KEYHOLE_OUTER_RADIUS = 1.05; export const KEYHOLE_OUTER_RADIUS = 1.05;
@@ -445,6 +449,12 @@ export class CelestialRing {
if (typeof document !== 'undefined' && document.hidden) return; if (typeof document !== 'undefined' && document.hidden) return;
governorRequestRender('celestial-ephemeris'); governorRequestRender('celestial-ephemeris');
}, 60_000); }, 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); this.setEnabled(this.enabled);
} }
@@ -801,7 +811,8 @@ export class CelestialRing {
return; return;
} }
const time = Cesium.JulianDate.now(); this._skyDate.setTime(skyTime());
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
if (!this._updateEphemeris(time)) return; if (!this._updateEphemeris(time)) return;
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
const camera = this.viewer.camera; const camera = this.viewer.camera;
@@ -890,6 +901,8 @@ export class CelestialRing {
clearInterval(this._ephemerisTimer); clearInterval(this._ephemerisTimer);
this._ephemerisTimer = null; this._ephemerisTimer = null;
} }
this._unsubscribeWorldTime?.();
this._unsubscribeWorldTime = null;
if (this._removePostRender) this._removePostRender(); if (this._removePostRender) this._removePostRender();
this._removePostRender = null; this._removePostRender = null;
if (this._shown()) { 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 // 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 // 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([ const VETTED_VARS = new Set([
'--dock-pinned-stack-height', '--dock-pinned-stack-height',
'--dock-lower-pinned-height', '--dock-lower-pinned-height',
'--dock-location-pinned-height', '--dock-location-pinned-height',
'--dock-presets-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). */ /** Rules whose final compound targets a modelled element (pseudo-elements aside). */
function ownBoxEntries() { function ownBoxEntries() {
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
if (term.startsWith('var(')) { if (term.startsWith('var(')) {
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim(); const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`); 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 continue; // proven non-negative; 0 is the conservative floor
} }
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`); 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'); assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
for (const decl of declared) { for (const decl of declared) {
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`); 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 layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
const start = layout.indexOf('_updateCommandDockTrayStack() {'); 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 // 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. // 8px at 720px, while the credit keeps its 2vh base throughout.
const failures = []; const failures = [];
for (const scenario of TRAY_SCENARIOS) { eachLift((lift) => {
for (const width of WIDTHS.filter((w) => w <= 900)) { for (const scenario of TRAY_SCENARIOS) {
for (const height of HEIGHTS) { for (const width of WIDTHS.filter((w) => w <= 900)) {
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height); for (const height of HEIGHTS) {
if (clearance < MIN_CLEARANCE_PX) { const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`); 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`); 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', 'none',
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`, `at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
); );
for (const height of HEIGHTS) { eachLift((lift) => {
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail'); for (const height of HEIGHTS) {
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height); const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`); 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(', ')}`); 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', () => { test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh'); const lift = 'var(--world-timeline-lift, 0px)';
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px'); 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( assert.equal(
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value, 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', '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 assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { gstime } from 'satellite.js'; import { gstime } from 'satellite.js';
import { worldClock } from '../time/worldClock.js';
import { createModel } from '../layers/launches/model.js'; import { createModel } from '../layers/launches/model.js';
import rocketLaunchesLayer, { import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest, _setRocketMissionOverlayHostForTest,
@@ -1240,7 +1241,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
rocketLaunchesLayer.init(viewer); rocketLaunchesLayer.init(viewer);
initialized = true; initialized = true;
await rocketLaunchesLayer.enable(); await rocketLaunchesLayer.enable();
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
await rocketLaunchesLayer.update(); 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; const entities = dataSources[0].entities.values;
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path'); 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); assert.equal(deselectedPublication[2][0].protected, false);
await rocketLaunchesLayer.disable(); await rocketLaunchesLayer.disable();
assert.equal(worldClock.coverage(), null, 'off, launches show no time');
assert.deepEqual(hostCalls.slice(-4), [ assert.deepEqual(hostCalls.slice(-4), [
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID], ['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false], ['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); const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
assert.deepEqual(launch.payloads, []); 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 { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs'; import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js'; 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 { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js'; import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import { import {
@@ -393,11 +398,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. * @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format.
*/ */
_formatUTC() { _formatUTC() {
const now = new Date(); const now = new Date(hudTime());
const y = now.getUTCFullYear(); const y = now.getUTCFullYear();
const mo = String(now.getUTCMonth() + 1).padStart(2, '0'); const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0'); const d = String(now.getUTCDate()).padStart(2, '0');
@@ -500,7 +505,7 @@ export class IntelHUD {
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
// Collection timestamp // Collection timestamp
const now = new Date(); const now = new Date(hudTime());
const h = String(now.getUTCHours()).padStart(2, '0'); const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0'); const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0'); const s = String(now.getUTCSeconds()).padStart(2, '0');
@@ -607,7 +612,7 @@ export class IntelHUD {
* @returns {number} Estimated sun elevation in degrees (negative = below horizon). * @returns {number} Estimated sun elevation in degrees (negative = below horizon).
*/ */
_estimateSunElevation(lat, lon) { _estimateSunElevation(lat, lon) {
const now = new Date(); const now = new Date(skyTime());
// Approximate local solar time by shifting UTC hours by longitude offset // Approximate local solar time by shifting UTC hours by longitude offset
const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15; const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15;
const solarNoon = 12; const solarNoon = 12;
+4 -1
View File
@@ -3223,5 +3223,8 @@
"Audio": "الصوت", "Audio": "الصوت",
"UTILITIES": "الأدوات", "UTILITIES": "الأدوات",
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم", "Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة" "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"World time": "وقت العالم",
"Back to live": "العودة إلى البث المباشر",
"Live": "مباشر"
} }
+4 -1
View File
@@ -2943,5 +2943,8 @@
"Audio": "Audio", "Audio": "Audio",
"UTILITIES": "WERKZEUGE", "UTILITIES": "WERKZEUGE",
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen", "Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien" "Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
"World time": "Weltzeit",
"Back to live": "Zurück zu live",
"Live": "Live"
} }
+4 -1
View File
@@ -3013,5 +3013,8 @@
"Right panels": "Paneles derechos", "Right panels": "Paneles derechos",
"UTILITIES": "UTILIDADES", "UTILITIES": "UTILIDADES",
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo", "Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos" "Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
"World time": "Hora mundial",
"Back to live": "Volver al directo",
"Live": "En directo"
} }
+4 -1
View File
@@ -3013,5 +3013,8 @@
"Audio": "Audio", "Audio": "Audio",
"UTILITIES": "OUTILS", "UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin", "Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers" "Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
"World time": "Heure du monde",
"Back to live": "Revenir au direct",
"Live": "Direct"
} }
+4 -1
View File
@@ -3013,5 +3013,8 @@
"Audio": "Audio", "Audio": "Audio",
"UTILITIES": "STRUMENTI", "UTILITIES": "STRUMENTI",
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno", "Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file" "Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
"World time": "Ora del mondo",
"Back to live": "Torna alla diretta",
"Live": "Diretta"
} }
+4 -1
View File
@@ -2873,5 +2873,8 @@
"Audio": "オーディオ", "Audio": "オーディオ",
"UTILITIES": "ユーティリティ", "UTILITIES": "ユーティリティ",
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください", "Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル" "Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"World time": "世界時刻",
"Back to live": "ライブに戻る",
"Live": "ライブ"
} }
+4 -1
View File
@@ -3013,5 +3013,8 @@
"Audio": "Áudio", "Audio": "Áudio",
"UTILITIES": "UTILITÁRIOS", "UTILITIES": "UTILITÁRIOS",
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho", "Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos" "Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
"World time": "Hora do mundo",
"Back to live": "Voltar ao vivo",
"Live": "Ao vivo"
} }
+4 -1
View File
@@ -3083,5 +3083,8 @@
"Right panels": "Правые панели", "Right panels": "Правые панели",
"UTILITIES": "ИНСТРУМЕНТЫ", "UTILITIES": "ИНСТРУМЕНТЫ",
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку", "Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы" "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"World time": "Мировое время",
"Back to live": "Вернуться к прямому эфиру",
"Live": "Сейчас"
} }
+4 -1
View File
@@ -3083,5 +3083,8 @@
"Audio": "Аудіо", "Audio": "Аудіо",
"UTILITIES": "ІНСТРУМЕНТИ", "UTILITIES": "ІНСТРУМЕНТИ",
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка", "Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли" "Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
"World time": "Світовий час",
"Back to live": "Повернутися до прямого ефіру",
"Live": "Зараз"
} }
+4 -1
View File
@@ -2873,5 +2873,8 @@
"Right panels": "右侧面板", "Right panels": "右侧面板",
"UTILITIES": "工具", "UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置", "Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件" "Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
"World time": "世界时间",
"Back to live": "返回实时",
"Live": "实时"
} }
+4 -1
View File
@@ -2873,5 +2873,8 @@
"Right panels": "右側面板", "Right panels": "右側面板",
"UTILITIES": "工具", "UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件" "Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
"World time": "世界時間",
"Back to live": "返回即時",
"Live": "即時"
} }
+1
View File
@@ -89,6 +89,7 @@
"Landsat 8/9", "Landsat 8/9",
"Latin", "Latin",
"Layout", "Layout",
"Live",
"London", "London",
"Los Angeles", "Los Angeles",
"MIL", "MIL",
+3 -2
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { import {
COURSE_HOLD_SPEED_MPS, COURSE_HOLD_SPEED_MPS,
lerpAngleDeg, lerpAngleDeg,
@@ -156,7 +157,7 @@ export function createMotion({
const renderTime = const renderTime =
sampledRenderTime ?? sampledRenderTime ??
Cesium.JulianDate.addSeconds( Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchRenderTime), julianAt(flightState.now(), flightState._scratchRenderTime),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchRenderTime, flightState._scratchRenderTime,
); );
@@ -332,7 +333,7 @@ export function createMotion({
const history = flightState._positionHistory.get(flightState._trackedIcao); const history = flightState._positionHistory.get(flightState._trackedIcao);
if (!history || history.length === 0) return true; if (!history || history.length === 0) return true;
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchWarmupTime, flightState._scratchWarmupTime,
); );
+8 -6
View File
@@ -12,6 +12,7 @@ function fixture() {
_scratchArc: {}, _scratchArc: {},
_scratchOffset: new Cesium.Cartesian3(), _scratchOffset: new Cesium.Cartesian3(),
_scratchRenderTime: new Cesium.JulianDate(), _scratchRenderTime: new Cesium.JulianDate(),
now: Date.now,
}; };
const motion = createMotion({ const motion = createMotion({
flightState, 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)); 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 { flightState, motion } = fixture();
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z'); const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
const history = [ 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.records.data.set('tracked', { velocity: 100, true_track: 90 });
flightState._positionHistory.set('tracked', history); flightState._positionHistory.set('tracked', history);
let elapsed = 60; let elapsed = 60;
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => { let reads = 0;
assert.equal(result, flightState._scratchRenderTime); flightState.now = () => {
return Cesium.JulianDate.addSeconds(start, elapsed, result); reads += 1;
}); return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000;
};
const out = new Cesium.Cartesian3(); const out = new Cesium.Cartesian3();
close( close(
motion._deadReckon('tracked', out), motion._deadReckon('tracked', out),
@@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
new Cesium.Cartesian3(), 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 * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js'; import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js'; import { civilAircraftModelSpec } from './modelSpec.js';
@@ -863,7 +864,7 @@ export function createRendering({
flightState._lastFleetTickMs = nowMs; flightState._lastFleetTickMs = nowMs;
// Every contact in this pass shares the same delayed display instant. // Every contact in this pass shares the same delayed display instant.
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(flightState._scratchFleetRenderTime), julianAt(flightState.now(), flightState._scratchFleetRenderTime),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchFleetRenderTime, flightState._scratchFleetRenderTime,
); );
+3
View File
@@ -1,6 +1,7 @@
import { FlightRecords } from './records.js'; import { FlightRecords } from './records.js';
import { createFlightFeed } from './ingestion.js'; import { createFlightFeed } from './ingestion.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { worldTimeFor } from '../../time/worldClock.js';
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
export function createFlightState({ source, services }) { export function createFlightState({ source, services }) {
@@ -12,6 +13,8 @@ export function createFlightState({ source, services }) {
...services.groundFloor, ...services.groundFloor,
}); });
flightState._cullPositions = new Map(); 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 /** 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 * 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 * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { formatNumber, t } from '../../i18n/index.js'; import { formatNumber, t } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js'; import { unitLabel } from '../../ui/unitLabels.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.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 // would draw the trail in front of the plane. They join the trail via _appendTrailFix
// as they age past the delay. // as they age past the delay.
const seedRenderTime = Cesium.JulianDate.addSeconds( const seedRenderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchWarmupTime, flightState._scratchWarmupTime,
); );
+3 -1
View File
@@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({
source, source,
viewAnchor = defaultGnssViewAnchor, viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(), now = () => Date.now(),
// The end of the window shown; retries and receipt times stay on `now`.
worldNow = now,
monotonicNow = () => performance.now(), monotonicNow = () => performance.now(),
windowMs: requestedWindowMs, windowMs: requestedWindowMs,
} = {}) { } = {}) {
@@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({
/** Age the store without new evidence, so expired cells leave the globe. */ /** Age the store without new evidence, so expired cells leave the globe. */
function expire() { function expire() {
pruneGnssObservations(_observations, now(), { windowMs }); pruneGnssObservations(_observations, worldNow(), { windowMs });
_cells = binGnssCells(_observations); _cells = binGnssCells(_observations);
render(); render();
} }
+1 -1
View File
@@ -26,7 +26,7 @@ export function createControls({ state: layerState, services, parts, source }) {
*/ */
getAnalystRecords(maxCount = Infinity) { getAnalystRecords(maxCount = Infinity) {
if (!layerState._enabled) return []; if (!layerState._enabled) return [];
const now = Date.now(); const now = layerState.now();
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null); const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
const result = []; const result = [];
for (const launch of layerState._launches || []) { for (const launch of layerState._launches || []) {
+1
View File
@@ -222,6 +222,7 @@ export function createIngestion({
layerState._lastUpdate = Date.now(); layerState._lastUpdate = Date.now();
layerState._lastError = null; layerState._lastError = null;
layerState._stale = stale === true; layerState._stale = stale === true;
layerState.coverageChanged();
} catch (error) { } catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) { if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message; layerState._lastError = error.message;
+1 -1
View File
@@ -105,7 +105,7 @@ export function createLaunchPad({
} }
const nowMs = Date.now(); const nowMs = Date.now();
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) { if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
layerState._missionRingDate.setTime(nowMs); layerState._missionRingDate.setTime(layerState.orbitNow());
parts.orbitRendering.updateMissionOrbitPrimitiveFrames( parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
layerState._missionRingDate, layerState._missionRingDate,
); );
+18
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js'; import { isPointerFree } from '../../data/inputOwnership.js';
import { worldClock } from '../../time/worldClock.js';
const HOVER_PICK_INTERVAL_MS = 120; const HOVER_PICK_INTERVAL_MS = 120;
@@ -119,6 +120,21 @@ export function createLifecycle({
}) { }) {
const { holdContinuousRender, releaseContinuousRender } = services.render; const { holdContinuousRender, releaseContinuousRender } = services.render;
let hover = null; 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 = { const methods = {
init(viewer) { init(viewer) {
@@ -194,6 +210,7 @@ export function createLifecycle({
} }
throw error; throw error;
} }
layerState.coverageChanged();
}, },
async _enableBody() { async _enableBody() {
@@ -231,6 +248,7 @@ export function createLifecycle({
parts.overlays.clearMissionOverlaySources(); parts.overlays.clearMissionOverlaySources();
await parts.ingestion.restoreSatelliteDependency(); await parts.ingestion.restoreSatelliteDependency();
parts.panel.renderMissionPanel(); parts.panel.renderMissionPanel();
layerState.coverageChanged();
}, },
async destroy(viewer) { async destroy(viewer) {
+1 -1
View File
@@ -124,7 +124,7 @@ export function createOrbitRendering({
collection.show = missionOrbitPrimitiveVisible(launch.id); collection.show = missionOrbitPrimitiveVisible(launch.id);
orbitFrameModelMatrix( orbitFrameModelMatrix(
satelliteTrack.gmstAtBake, satelliteTrack.gmstAtBake,
new Date(), new Date(layerState.orbitNow()),
collection.modelMatrix, collection.modelMatrix,
); );
layerState._viewer.scene.primitives.add(collection); layerState._viewer.scene.primitives.add(collection);
+2 -2
View File
@@ -394,7 +394,7 @@ export function createRendering({
orbitCurrent.latitude, orbitCurrent.latitude,
orbitCurrent.altitude, orbitCurrent.altitude,
); );
const liveTime = new Date(); const liveTime = new Date(layerState.orbitNow());
const liveTelemetry = { speedMps: null }; const liveTelemetry = { speedMps: null };
layerState._satelliteTelemetry.set(launch.id, liveTelemetry); layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
let liveFrameNumber = -1; let liveFrameNumber = -1;
@@ -405,7 +405,7 @@ export function createRendering({
layerState._viewer?.scene?.frameState?.frameNumber ?? -1; layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
liveFrameNumber = frameNumber; liveFrameNumber = frameNumber;
const nowMs = Date.now(); const nowMs = layerState.orbitNow();
liveTime.setTime(nowMs); liveTime.setTime(nowMs);
if (satelliteTrack) { if (satelliteTrack) {
const propagated = const propagated =
+13
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
export function createState({ services }) { export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
@@ -147,6 +148,18 @@ export function createState({ services }) {
state._missionRingDate = new Date(0); 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._missionOrbitPatternRegistered = false;
state._pathDistanceCache = new WeakMap(); state._pathDistanceCache = new WeakMap();
+3 -2
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { import {
lerpAngleDeg, lerpAngleDeg,
speedRamp, speedRamp,
@@ -67,7 +68,7 @@ export function createMotion({
// Render one poll interval behind real time so we interpolate between // Render one poll interval behind real time so we interpolate between
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchRenderTime, flightState._scratchRenderTime,
); );
@@ -213,7 +214,7 @@ export function createMotion({
const history = flightState._positionHistory.get(flightState._trackedIcao); const history = flightState._positionHistory.get(flightState._trackedIcao);
if (!history || history.length === 0) return true; if (!history || history.length === 0) return true;
const renderTime = Cesium.JulianDate.addSeconds( const renderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchWarmupTime, flightState._scratchWarmupTime,
); );
+3
View File
@@ -1,6 +1,7 @@
import { MilitaryFlightRecords } from './records.js'; import { MilitaryFlightRecords } from './records.js';
import { createMilitaryFeed } from './ingestion.js'; import { createMilitaryFeed } from './ingestion.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { worldTimeFor } from '../../time/worldClock.js';
export function createFlightState({ source, services }) { export function createFlightState({ source, services }) {
const { createGroundSnap } = services.groundSnap; const { createGroundSnap } = services.groundSnap;
@@ -11,6 +12,8 @@ export function createFlightState({ source, services }) {
...services.groundFloor, ...services.groundFloor,
}); });
flightState._cullPositions = new Map(); 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: /** 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 * 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 * as Cesium from 'cesium';
import { julianAt } from '../../time/julian.js';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.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 // 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. // would draw the trail in front of the plane. They join via _appendTrailFix as they age.
const seedRenderTime = Cesium.JulianDate.addSeconds( const seedRenderTime = Cesium.JulianDate.addSeconds(
Cesium.JulianDate.now(), julianAt(flightState.now()),
-RENDER_DELAY_SEC, -RENDER_DELAY_SEC,
flightState._scratchWarmupTime, flightState._scratchWarmupTime,
); );
+4 -2
View File
@@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
1, 1,
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), 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++) { for (let i = 0; i < perFrame; i++) {
if (layerState._denseCursor >= layerState._denseIds.length) if (layerState._denseCursor >= layerState._denseIds.length)
layerState._denseCursor = 0; layerState._denseCursor = 0;
@@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
const entries = parts.orbits.parseTLE(text); const entries = parts.orbits.parseTLE(text);
const style = POINT_STYLES.dense; const style = POINT_STYLES.dense;
const now = new Date(); const now = new Date(layerState.now());
let added = 0; let added = 0;
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { 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._count = layerState._points.size;
layerState._catalogRevision++; layerState._catalogRevision++;
layerState.coverageChanged();
layerState._denseStatus = 'ready'; layerState._denseStatus = 'ready';
console.log( console.log(
`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`, `[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._denseCursor = 0;
layerState._count = layerState._points.size; layerState._count = layerState._points.size;
layerState._catalogRevision++; layerState._catalogRevision++;
layerState.coverageChanged();
} }
return { return {
_abortActiveUpdates, _abortActiveUpdates,
+4 -1
View File
@@ -190,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) {
if (noradId === null) return null; if (noradId === null) return null;
const sat = layerState._catalog.get(noradId); 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; if (!pos) return null;
return { return {
+2 -1
View File
@@ -103,7 +103,7 @@ export function createIngestion({
layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog
parts.labels._syncIssOverlay(); parts.labels._syncIssOverlay();
const now = new Date(); const now = new Date(layerState.now());
// Process all TLE entries in CATALOG_GROUPS order // Process all TLE entries in CATALOG_GROUPS order
const allEntries = []; const allEntries = [];
@@ -195,6 +195,7 @@ export function createIngestion({
// the rebuild must not block on that. // the rebuild must not block on that.
void parts.tracking._reconcileTrackedSubjectContext(); void parts.tracking._reconcileTrackedSubjectContext();
parts.tracking._applyPendingTrackingRestore(); parts.tracking._applyPendingTrackingRestore();
layerState.coverageChanged();
} catch (e) { } catch (e) {
if (updateSignal.aborted || e?.name === 'AbortError') { if (updateSignal.aborted || e?.name === 'AbortError') {
throw new DOMException('Satellite update aborted', 'AbortError'); throw new DOMException('Satellite update aborted', 'AbortError');
+2
View File
@@ -33,6 +33,8 @@ function ingestionFor(catalogText) {
_denseLoadToken: 0, _denseLoadToken: 0,
_catalogRevision: 0, _catalogRevision: 0,
_params: { catalog: 'core', showOrbits: false }, _params: { catalog: 'core', showOrbits: false },
now: Date.now,
coverageChanged() {},
}; };
const parts = { const parts = {
orbits: { orbits: {
+20 -1
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; 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({ export function createLifecycle({
state: layerState, state: layerState,
@@ -8,6 +9,20 @@ export function createLifecycle({
source, source,
}) { }) {
const { clearFocusTarget } = services.focus; 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 { holdContinuousRender, releaseContinuousRender } = services.render;
const { registerPickOwner, unregisterPickOwner } = services.picking; const { registerPickOwner, unregisterPickOwner } = services.picking;
@@ -46,6 +61,7 @@ export function createLifecycle({
layerState._denseStatus = 'idle'; layerState._denseStatus = 'idle';
layerState._denseError = null; layerState._denseError = null;
layerState._catalogRevision++; layerState._catalogRevision++;
layerState.coverageChanged();
// Point primitives for satellite dots // Point primitives for satellite dots
layerState._pointCollection = new Cesium.PointPrimitiveCollection(); layerState._pointCollection = new Cesium.PointPrimitiveCollection();
@@ -92,6 +108,7 @@ export function createLifecycle({
); );
} }
parts.tracking._applyPendingTrackingRestore(); parts.tracking._applyPendingTrackingRestore();
layerState.coverageChanged();
}, },
disable(viewer) { disable(viewer) {
@@ -119,6 +136,7 @@ export function createLifecycle({
layerState._preRenderListener(); layerState._preRenderListener();
layerState._preRenderListener = null; layerState._preRenderListener = null;
} }
layerState.coverageChanged();
}, },
destroy(viewer) { destroy(viewer) {
@@ -176,6 +194,7 @@ export function createLifecycle({
failedGroups: [], failedGroups: [],
}; };
layerState._viewer = null; layerState._viewer = null;
layerState.coverageChanged();
}, },
}; };
+3 -3
View File
@@ -137,7 +137,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
lonDeg, lonDeg,
minElevDeg = 10, minElevDeg = 10,
requireVisible = false, requireVisible = false,
fromMs = Date.now(), fromMs = layerState.now(),
}) { }) {
return getNextSatellitePass(ISS_NORAD, { return getNextSatellitePass(ISS_NORAD, {
latDeg, latDeg,
@@ -167,7 +167,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
lonDeg, lonDeg,
minElevDeg = 10, minElevDeg = 10,
requireVisible = false, requireVisible = false,
fromMs = Date.now(), fromMs = layerState.now(),
}, },
) { ) {
const id = Number(noradId); const id = Number(noradId);
@@ -271,7 +271,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
} }
function orbitTrackFromRecord(name, satrec) { function orbitTrackFromRecord(name, satrec) {
const referenceDate = new Date(); const referenceDate = new Date(layerState.now());
const current = propagatePosition(satrec, referenceDate); const current = propagatePosition(satrec, referenceDate);
if (!current) return null; if (!current) return null;
return { return {
+9
View File
@@ -28,6 +28,15 @@ export const ORBIT_PATH_STEPS = 180;
// points per orbital path // points per orbital path
export const POSITION_UPDATE_MS = 1000; 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) // re-propagate every 1s (SGP4 is smooth at this rate)
export const RING_ROTATION_MS = 1000; export const RING_ROTATION_MS = 1000;
+1 -1
View File
@@ -79,7 +79,7 @@ export function createRecords({ state, parts }) {
const limit = Number.isFinite(maxCount) const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount)) ? Math.max(1, Math.floor(maxCount))
: 2000; : 2000;
const now = new Date(); const now = new Date(state.now());
const result = []; const result = [];
const emit = (noradId, sat) => { const emit = (noradId, sat) => {
if (result.length >= limit) return false; if (result.length >= limit) return false;
+19 -5
View File
@@ -1,6 +1,11 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { gstime } from 'satellite.js'; 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({ export function createRendering({
state: layerState, state: layerState,
@@ -42,7 +47,7 @@ export function createRendering({
const sat = layerState._catalog.get(noradId); const sat = layerState._catalog.get(noradId);
if (!sat || !layerState._viewer) return; if (!sat || !layerState._viewer) return;
const bakeDate = new Date(); const bakeDate = new Date(layerState.now());
const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate);
if (basePositions.length < 2) return; if (basePositions.length < 2) return;
@@ -75,6 +80,8 @@ export function createRendering({
layerState._orbitPaths.set(noradId, { layerState._orbitPaths.set(noradId, {
primitive, primitive,
color: pathColor,
bakeMs: bakeDate.getTime(),
// Same Date object as computeOrbitPath's internal fixedGmst → identical // Same Date object as computeOrbitPath's internal fixedGmst → identical
// GMST value (gstime is pure), so delta starts at exactly 0 and the // GMST value (gstime is pure), so delta starts at exactly 0 and the
// initial identity modelMatrix is correct until the first ring tick. // initial identity modelMatrix is correct until the first ring tick.
@@ -113,8 +120,15 @@ export function createRendering({
function _updateOrbitPathRotations(nowDate) { function _updateOrbitPathRotations(nowDate) {
if (layerState._orbitPaths.size === 0) return; if (layerState._orbitPaths.size === 0) return;
for (const path of layerState._orbitPaths.values()) { for (const [noradId, path] of [...layerState._orbitPaths]) {
if (!path.primitive) continue; 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( parts.orbits.orbitFrameModelMatrix(
path.gmstAtBake, path.gmstAtBake,
nowDate, nowDate,
@@ -143,7 +157,7 @@ export function createRendering({
*/ */
function _propagateAll() { function _propagateAll() {
const now = new Date(); const now = new Date(layerState.now());
let updated = 0; let updated = 0;
for (const [noradId, sat] of layerState._catalog) { for (const [noradId, sat] of layerState._catalog) {
@@ -211,7 +225,7 @@ export function createRendering({
layerState._params.showOrbits && layerState._params.showOrbits &&
now - layerState._lastRingRotation >= RING_ROTATION_MS now - layerState._lastRingRotation >= RING_ROTATION_MS
) { ) {
_updateOrbitPathRotations(new Date(now)); _updateOrbitPathRotations(new Date(layerState.now()));
layerState._lastRingRotation = now; layerState._lastRingRotation = now;
} }
} }
+9
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
export function createState({ services }) { export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
@@ -151,6 +152,14 @@ export function createState({ services }) {
state._trackedFrameNowForTest = null; 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 // Scratch variables
state._scratchCartesian = new Cesium.Cartesian3(); state._scratchCartesian = new Cesium.Cartesian3();
+5 -2
View File
@@ -154,7 +154,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
sat.satrec, sat.satrec,
layerState._trackedFrameNowForTest layerState._trackedFrameNowForTest
? new Date(layerState._trackedFrameNowForTest()) ? new Date(layerState._trackedFrameNowForTest())
: new Date(), : new Date(layerState.now()),
); );
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
layerState._trackedFrameGeo = pos; 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 // Comfortable tracking landing: ~726 km back for LEO (user-validated
// "slightly zoomed out" framing — no stutter, label reads cleanly), scaled // "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. // 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 = const viewScale =
initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M
? TRACK_VIEW_FROM_HIGH_SCALE ? TRACK_VIEW_FROM_HIGH_SCALE
+6 -2
View File
@@ -8,6 +8,7 @@ import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js'; import { createViewerHost } from './viewerHost.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js'; import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js'; import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { worldTimeFor } from '../../time/worldClock.js';
import { decodeParams, encodeParams } from './params.js'; import { decodeParams, encodeParams } from './params.js';
import { import {
composeUIState, composeUIState,
@@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000;
* a time. * a time.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options * @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({ export function createStreetLevelLayer({
providers: definitions, providers: definitions,
services = {}, services = {},
@@ -93,7 +97,7 @@ export function createStreetLevelLayer({
return Object.freeze({ return Object.freeze({
services: state.services, services: state.services,
/** Resolved against now, so "since N days" keeps up with the clock. */ /** 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. */ /** The layer and this provider's switch are both on. */
isActive: () => state.enabled && entry.on, isActive: () => state.enabled && entry.on,
notify, notify,
@@ -356,7 +360,7 @@ export function createStreetLevelLayer({
const filter = normalizeFilter(next, state.filter); const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return; if (sameFilter(filter, state.filter)) return;
state.filter = filter; state.filter = filter;
const resolved = resolveFilter(filter); const resolved = resolveFilter(filter, streetLevelTime());
for (const entry of state.providers.values()) for (const entry of state.providers.values())
entry.instance.setFilter(resolved); entry.instance.setFilter(resolved);
notify(); notify();
+1
View File
@@ -3927,6 +3927,7 @@ test('parked transit reprojects held and reported courses at the bounded camera
_rotationRevision: -1, _rotationRevision: -1,
_cameraRevision: 0, _cameraRevision: 0,
_rotationDirty: false, _rotationDirty: false,
now: () => Date.now(),
}; };
const noop = () => {}; const noop = () => {};
const rendering = createRendering({ const rendering = createRendering({
+6 -6
View File
@@ -108,7 +108,7 @@ export function createRendering({ state, services, parts }) {
} }
function sampleIdle(entry) { function sampleIdle(entry) {
if (!state._moving.has(entry)) { if (!state._moving.has(entry)) {
updatePlayback(entry, Date.now(), performance.now()); updatePlayback(entry, state.now(), performance.now());
placeSample(entry); placeSample(entry);
} }
} }
@@ -172,7 +172,7 @@ export function createRendering({ state, services, parts }) {
() => { () => {
entry.wakeTimer = null; entry.wakeTimer = null;
if (!state._enabled || !state._vehicles.has(entry.key)) return; if (!state._enabled || !state._vehicles.has(entry.key)) return;
updatePlayback(entry, Date.now(), performance.now()); updatePlayback(entry, state.now(), performance.now());
placeSample(entry); placeSample(entry);
schedulePlayback(entry); schedulePlayback(entry);
syncRenderHold(); syncRenderHold();
@@ -388,7 +388,7 @@ export function createRendering({ state, services, parts }) {
) )
return; return;
if (!state._enabled) return; if (!state._enabled) return;
const now = Date.now(); const now = state.now();
if (state._vehicles.size === 0) { if (state._vehicles.size === 0) {
state._moving.clear(); state._moving.clear();
syncRenderHold(); syncRenderHold();
@@ -424,7 +424,7 @@ export function createRendering({ state, services, parts }) {
} }
parts.trails.update(); parts.trails.update();
refreshRotations(now); refreshRotations(Date.now());
const selected = state._vehicles.get(state._selectedKey); const selected = state._vehicles.get(state._selectedKey);
if (selected && state._visible.has(selected)) { if (selected && state._visible.has(selected)) {
selected.marker.rotation = Number.isFinite(selected.courseDeg) 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); const noSample = !entry.sample || !Number.isFinite(entry.sample.lat);
if (entry.track?.count && (!state._visible.has(entry) || noSample)) { if (entry.track?.count && (!state._visible.has(entry) || noSample)) {
entry.sample ||= {}; entry.sample ||= {};
updatePlayback(entry, now, performance.now()); updatePlayback(entry, state.now(), performance.now());
} }
sphere.center = sphere.center =
state._visible.has(entry) || !entry.sample state._visible.has(entry) || !entry.sample
@@ -557,7 +557,7 @@ export function createRendering({ state, services, parts }) {
} }
function resumePlayback() { function resumePlayback() {
if (!state._enabled) return; if (!state._enabled) return;
const now = Date.now(), const now = state.now(),
mono = performance.now(); mono = performance.now();
const entries = new Set(state._visible); const entries = new Set(state._visible);
const selected = state._vehicles.get(state._selectedKey); const selected = state._vehicles.get(state._selectedKey);
+3
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createHistoryBudget } from '../../data/contactPlayback.js'; 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, * One Transit layer's mutable scene state. Every field lives on the instance,
@@ -14,6 +15,8 @@ export function createState({ services }) {
const state = {}; const state = {};
state._historyBudget = createHistoryBudget(); 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({ state.DEFAULT_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries, 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 RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_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 GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 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({ export function createWeatherClock({
now = Date.now, now = Date.now,
setTimeout = globalThis.setTimeout, setTimeout = globalThis.setTimeout,
clearTimeout = globalThis.clearTimeout, clearTimeout = globalThis.clearTimeout,
world = createWorldClock({ now }),
} = {}) { } = {}) {
const registry = new Map(); const registry = new Map();
const listeners = new Set(); const listeners = new Set();
let mode = 'latest'; let mode = 'latest';
let target = null; let target = null;
let playing = false; 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 generation = 0;
let timer = null; let timer = null;
let pending = null; let pending = null;
@@ -65,11 +81,32 @@ export function createWeatherClock({
timer = null; timer = null;
}; };
const canPlay = () => getTimeline().length >= 2; 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() { function schedule() {
cancelTimer(); cancelTimer();
if (!playing || destroyed || pending) return; if (!playing || destroyed || pending) return;
if (!canPlay()) { if (!canPlay()) {
playing = false; stopPlaying();
notify(); notify();
return; return;
} }
@@ -92,7 +129,8 @@ export function createWeatherClock({
if (destroyed) return Promise.resolve(false); if (destroyed) return Promise.resolve(false);
cancelTimer(); cancelTimer();
const owner = ++generation; const owner = ++generation;
if (!canPlay()) playing = false; if (!canPlay()) stopPlaying();
applied = selections();
for (const entry of registry.values()) entry.controller?.abort(); for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier. // Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => { const tasks = [...registry.values()].map((entry) => {
@@ -107,12 +145,13 @@ export function createWeatherClock({
const result = Promise.allSettled(tasks).then(() => { const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false; if (destroyed || owner !== generation) return false;
pending = null; pending = null;
if (!canPlay()) playing = false; if (!canPlay()) stopPlaying();
notify(); notify();
schedule(); schedule();
return true; return true;
}); });
pending = result; pending = result;
if (moving) moved ??= result;
notify(); notify();
return result; return result;
} }
@@ -123,11 +162,41 @@ export function createWeatherClock({
throw new Error(`Duplicate weather product: ${product.id}`); throw new Error(`Duplicate weather product: ${product.id}`);
const entry = { product, controller: null }; const entry = { product, controller: null };
registry.set(product.id, entry); 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(); void api.refresh();
return () => { return () => {
if (registry.get(product.id) !== entry) return; if (registry.get(product.id) !== entry) return;
entry.controller?.abort(); entry.controller?.abort();
registry.delete(product.id); registry.delete(product.id);
releases.get(product.id)?.();
releases.delete(product.id);
void api.refresh(); void api.refresh();
}; };
}, },
@@ -140,9 +209,11 @@ export function createWeatherClock({
const normalized = new Date(time).toISOString(); const normalized = new Date(time).toISOString();
if (mode === 'history' && target === normalized) if (mode === 'history' && target === normalized)
return pending ?? Promise.resolve(true); return pending ?? Promise.resolve(true);
mode = 'history'; return (
target = normalized; move(() => world.seek(Date.parse(normalized))) ??
return transition(); pending ??
Promise.resolve(true)
);
}, },
step(direction) { step(direction) {
if (destroyed || ![-1, 1].includes(direction)) if (destroyed || ![-1, 1].includes(direction))
@@ -159,10 +230,9 @@ export function createWeatherClock({
}, },
latest() { latest() {
if (destroyed) return Promise.resolve(false); if (destroyed) return Promise.resolve(false);
mode = 'latest'; stopPlaying();
target = null; if (mode === 'latest') return transition();
playing = false; return move(() => world.goLive()) ?? pending ?? Promise.resolve(true);
return transition();
}, },
play() { play() {
if (destroyed || playing || !canPlay()) if (destroyed || playing || !canPlay())
@@ -170,18 +240,28 @@ export function createWeatherClock({
playing = true; playing = true;
if (mode === 'latest') { if (mode === 'latest') {
const timeline = getTimeline(); const timeline = getTimeline();
return api.setTarget( const start =
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0], 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(); notify();
schedule(); schedule();
return pending ?? Promise.resolve(true); return pending ?? Promise.resolve(true);
}, },
pause() { pause() {
cancelTimer(); cancelTimer();
if (!playing) return; if (!playing) {
playing = false; // The world may be playing on its own; a pause or step here holds it.
if (world.isPlaying()) world.pause();
return;
}
stopPlaying();
notify(); notify();
}, },
togglePlay() { togglePlay() {
@@ -189,8 +269,10 @@ export function createWeatherClock({
}, },
refresh() { refresh() {
if (destroyed) return Promise.resolve(false); if (destroyed) return Promise.resolve(false);
// New frames, or products added or dropped.
world.coverageChanged();
if (!canPlay()) { if (!canPlay()) {
playing = false; stopPlaying();
cancelTimer(); cancelTimer();
} }
if (mode === 'history') return transition(); if (mode === 'history') return transition();
@@ -204,15 +286,44 @@ export function createWeatherClock({
}, },
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
stopPlaying();
destroyed = true; destroyed = true;
++generation; ++generation;
playing = false;
cancelTimer(); 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(); for (const entry of registry.values()) entry.controller?.abort();
registry.clear(); registry.clear();
listeners.clear(); listeners.clear();
pending = null; 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; return api;
} }
+218
View File
@@ -7,6 +7,7 @@ import {
GLOBAL_INFRARED_MAX_GAP_MS, GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS,
} from './clock.js'; } from './clock.js';
import { createWorldClock } from '../../time/worldClock.js';
const time = (minutes) => const time = (minutes) =>
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString(); new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
@@ -37,7 +38,15 @@ function harness(t) {
let at = Date.parse(time(20)); let at = Date.parse(time(20));
const timers = new Map(); const timers = new Map();
let id = 0; let id = 0;
let mono = 0;
const world = createWorldClock({
now: () => at,
monotonicNow: () => mono,
setInterval: () => 0,
clearInterval() {},
});
const clock = createWeatherClock({ const clock = createWeatherClock({
world,
now: () => at, now: () => at,
setTimeout(fn, delay) { setTimeout(fn, delay) {
timers.set(++id, { fn, due: at + delay }); timers.set(++id, { fn, due: at + delay });
@@ -50,9 +59,11 @@ function harness(t) {
t.after(() => clock.destroy()); t.after(() => clock.destroy());
return { return {
clock, clock,
world,
timers, timers,
tick(ms) { tick(ms) {
at += ms; at += ms;
mono += ms;
for (const [id, timer] of [...timers]) { for (const [id, timer] of [...timers]) {
if (timer.due > at) continue; if (timer.due > at) continue;
timers.delete(id); timers.delete(id);
@@ -275,3 +286,210 @@ test('registration joins history; unregister and destroy abort work and release
assert.deepEqual(clock.getTimeline(), []); assert.deepEqual(clock.getTimeline(), []);
assert.equal(await clock.setTarget(time(5)), false); 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'); : t('Unavailable');
/** A per-application observation layer, using the existing layer lifecycle and /** 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({ export function createWeatherLayer({
feed, feed,
clock, clock,
+14 -2
View File
@@ -31,6 +31,7 @@ import {
} from '../data/detectionPolicy.js'; } from '../data/detectionPolicy.js';
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { VIEW_STYLES } from '../view/index.js'; import { VIEW_STYLES } from '../view/index.js';
import { isWorldTime } from '../time/worldClock.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js'; import { RETIRED_STYLES } from '../visualStylePolicy.js';
import { import {
GLOBAL_POST_DEFAULTS, GLOBAL_POST_DEFAULTS,
@@ -373,6 +374,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([
{ number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 },
{ number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null },
{ number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, { 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 FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f]));
const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
@@ -732,6 +734,9 @@ export function normalizeShareViewState(input = {}) {
: null; : null;
state.panelState = normalizePanelState(source.panelState); state.panelState = normalizePanelState(source.panelState);
state.sharedAtMs = epochMsOr(source.sharedAtMs); state.sharedAtMs = epochMsOr(source.sharedAtMs);
state.worldTime = isWorldTime(source.worldTime)
? Math.floor(source.worldTime / 1000) * 1000
: null;
let view = normalizeView(source.view); let view = normalizeView(source.view);
if (state.layerState) view = liftTrackedEntity(state.layerState, view); if (state.layerState) view = liftTrackedEntity(state.layerState, view);
if (view.kind === 'thing') { if (view.kind === 'thing') {
@@ -948,8 +953,9 @@ export function encodeShareView(input) {
writer.sintField(field.number, signedAngle(state[field.key])); writer.sintField(field.number, signedAngle(state[field.key]));
break; break;
case 'sharedAtMs': case 'sharedAtMs':
if (state.sharedAtMs !== null) case 'worldTime':
writer.sintField(field.number, epochSeconds(state.sharedAtMs)); if (state[field.key] !== null)
writer.sintField(field.number, epochSeconds(state[field.key]));
break; break;
case 'flags': { case 'flags': {
let bits = 0; let bits = 0;
@@ -1347,6 +1353,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
raw.sharedAtMs = raw.sharedAtMs =
createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; 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')) if (get('layerState'))
raw.layerState = decodeLayers( raw.layerState = decodeLayers(
+21
View File
@@ -361,6 +361,7 @@ test('every registered field round-trips at once', () => {
})), })),
}, },
sharedAtMs: NOW_MS, sharedAtMs: NOW_MS,
worldTime: NOW_MS - 6 * 3_600_000,
}; };
// A thing view derives its camera (roll 0), so the camera fields are // A thing view derives its camera (roll 0), so the camera fields are
// checked on the same state as a place view. // 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], [25, 'scopeFeatherPct', 100],
[26, 'scopeTerminusPct', null], [26, 'scopeTerminusPct', null],
[27, 'styleParams', BY_STYLE], [27, 'styleParams', BY_STYLE],
[28, 'worldTime', null],
], ],
); );
assert.deepEqual( assert.deepEqual(
@@ -1338,3 +1340,22 @@ test('a layer option filed under a mirror layer is ignored', () => {
{ scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, { 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, normalizeAllocationStrategy,
} from './data/detectionPolicy.js'; } from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.js'; import { clampScopeTerminusPct } from './scopeMask.js';
import { worldClock as pageWorldClock } from './time/worldClock.js';
import { clampScopeFeatherPct } from './scopeMaskPolicy.js'; import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
import { import {
decodeLayerStateParams, decodeLayerStateParams,
@@ -68,9 +69,11 @@ export class ShareLinkManager {
isNavigationCurrent, isNavigationCurrent,
cancelOwnedNavigation, cancelOwnedNavigation,
initialShareCode = null, initialShareCode = null,
worldClock = pageWorldClock,
} = {}, } = {},
) { ) {
this.viewer = viewer; this.viewer = viewer;
this._worldClock = worldClock;
// A host may hand over the share code to open instead of the address. // A host may hand over the share code to open instead of the address.
this._initialShareCode = this._initialShareCode =
typeof initialShareCode === 'string' ? initialShareCode : null; 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 * 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), * `shareView` (what the view follows, with its offset and last-known fix)
* or null when the code is refused. * and `worldTime` (null when the link was made live), or null when the
* code is refused.
*/ */
_parseShareCode(code) { _parseShareCode(code) {
const result = decodeShareView(code); const result = decodeShareView(code);
@@ -174,6 +178,7 @@ export class ShareLinkManager {
} }
const state = toLegacyShareState(result.state); const state = toLegacyShareState(result.state);
state.shareView = result.state.view; state.shareView = result.state.view;
state.worldTime = result.state.worldTime;
return state; return state;
} }
@@ -592,6 +597,10 @@ export class ShareLinkManager {
// that state in the share format. // that state in the share format.
const state = fromLegacyShareState(this._parseShareHash(params.toString())); const state = fromLegacyShareState(this._parseShareHash(params.toString()));
state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; 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; let subject = null;
try { try {
subject = this._followSubjectProvider?.({ nowMs }) ?? null; 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(); 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, cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/, /#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'); assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
const credits = read('./styles/command-dock-trays.css'); const credits = read('./styles/command-dock-trays.css');
const creditOffsets = [ const creditOffsets = [
...credits.matchAll( ...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); assert.ok(creditOffsets.length > 0);
@@ -738,7 +738,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
); );
assert.match( assert.match(
cyberStyles, 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( assert.match(
cyberStyles, cyberStyles,
@@ -793,7 +793,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
cyberStyles, cyberStyles,
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/, /#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.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
assert.match(cyberStyles, /data-layer-id='satellites'/); assert.match(cyberStyles, /data-layer-id='satellites'/);
assert.match(cyberStyles, /#search-toggle::before/); 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) => const lift = (block) =>
Number( 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 z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
const idleCredit = rule( const idleCredit = rule(
+2
View File
@@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [
'#gev-voice-control', '#gev-voice-control',
'#pp-toggles', '#pp-toggles',
'#param-slider-panel', '#param-slider-panel',
'#world-timeline',
].join(', '); ].join(', ');
/** /**
* Fixed UI regions that can occupy the right control lane. Runtime rectangle * 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', '#cesium-credits .cesium-credit-textContainer',
'#command-dock', '#command-dock',
'#gev-voice-control', '#gev-voice-control',
'#world-timeline',
].join(', '); ].join(', ');
export class PanelLayoutController { export class PanelLayoutController {
constructor({ constructor({
+12
View File
@@ -4,6 +4,16 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { t, msg, formatDate } from '../i18n/index.js'; import { t, msg, formatDate } from '../i18n/index.js';
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
import { UiLifetime } from './uiLifetime.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. */ /** How long a shared vessel may take to appear in the vessel feed. */
export const SHARED_VESSEL_WAIT_MS = 120_000; export const SHARED_VESSEL_WAIT_MS = 120_000;
@@ -146,6 +156,7 @@ export class ShareRestoration {
: Promise.resolve({ status: 'not-requested', share: null, layers: [] }); : Promise.resolve({ status: 'not-requested', share: null, layers: [] });
if (savedState) { if (savedState) {
this._hasShareState = true; this._hasShareState = true;
applySharedWorldTime(savedState);
// Reserve camera authority now; the delayed mesh-friendly flight may // Reserve camera authority now; the delayed mesh-friendly flight may
// run only if no newer user, voice, or tracking navigation has won. // run only if no newer user, voice, or tracking navigation has won.
this._initialShareNavigationGeneration = this._initialShareNavigationGeneration =
@@ -301,6 +312,7 @@ export class ShareRestoration {
layers: [], layers: [],
}); });
this._initialShareState = state; this._initialShareState = state;
applySharedWorldTime(state);
this._initialShareSelectionSuperseded = false; this._initialShareSelectionSuperseded = false;
this._hasShareState = true; this._hasShareState = true;
this._navigatedShareRestoreActive = 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
@@ -870,7 +870,7 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
position: fixed; position: fixed;
z-index: 146; z-index: 146;
right: 36px; right: 36px;
bottom: calc(2vh + 4.5rem); bottom: calc(2vh + 4.5rem + var(--world-timeline-lift, 0px));
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
@@ -2049,7 +2049,7 @@ body.cockpit-mode #intel-hud .hud-right-edge {
@media (max-width: 760px) { @media (max-width: 760px) {
#view-switcher { #view-switcher {
right: 16px; right: 16px;
bottom: 16px; bottom: calc(16px + var(--world-timeline-lift, 0px));
} }
body.cockpit-mode #view-switcher { body.cockpit-mode #view-switcher {
right: auto; right: auto;
+3 -3
View File
@@ -377,15 +377,15 @@
/* Compact dock clearance keeps the reference-like low profile. */ /* Compact dock clearance keeps the reference-like low profile. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { 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 { #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 { #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) { @media (max-width: 900px) {
+6 -6
View File
@@ -455,7 +455,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
) )
#cesium-credits { #cesium-credits {
right: auto; right: auto;
bottom: calc(2vh + 5rem); bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
left: 1.5rem; left: 1.5rem;
width: auto !important; width: auto !important;
max-width: calc(100vw - 3rem); 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-left,
#intel-hud[data-variant='tactical'] .hud-bottom-right { #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 { #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'] :root[data-scope-mask='off']
#intel-hud[data-variant='minimal'] #intel-hud[data-variant='minimal']
.hud-bottom-left { .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 { #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) { @media (max-width: 900px) {
@@ -487,7 +487,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
#intel-hud[data-variant='minimal'].active #intel-hud[data-variant='minimal'].active
) )
#cesium-credits { #cesium-credits {
bottom: calc(2vh + 5rem); bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
left: 0.75rem; left: 0.75rem;
max-width: calc(100vw - 1.5rem); max-width: calc(100vw - 1.5rem);
} }
+9 -8
View File
@@ -2,7 +2,8 @@
#command-dock { #command-dock {
position: fixed; position: fixed;
right: auto; right: auto;
bottom: 2vh; /* Above the world timeline while it shows (world-timeline.css). */
bottom: calc(2vh + var(--world-timeline-lift, 0px));
left: 50%; left: 50%;
z-index: 145; z-index: 145;
display: grid; display: grid;
@@ -216,7 +217,7 @@
/* Keep required map attribution clear of the full-width command surface. */ /* Keep required map attribution clear of the full-width command surface. */
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits { 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( 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 { #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 { #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) { @media (max-width: 980px) {
@@ -275,21 +276,21 @@ body.recording-mode #cesium-credits {
} }
body:not(.ui-clean-view):not(.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 { #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 { #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) { @media (max-width: 720px) {
#command-dock { #command-dock {
bottom: 8px; bottom: calc(8px + var(--world-timeline-lift, 0px));
} }
#command-dock .location-search-wrap { #command-dock .location-search-wrap {
+5 -5
View File
@@ -1585,7 +1585,7 @@
} }
#intel-hud[data-variant='cyber'] .hud-bottom-bar { #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); width: min(48vw, 680px);
} }
@@ -1596,13 +1596,13 @@
#intel-hud[data-variant='cyber'] .hud-bottom-left, #intel-hud[data-variant='cyber'] .hud-bottom-left,
#intel-hud[data-variant='cyber'] .hud-bottom-right { #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 /* Attribution's logo row extends above its text baseline. Reserve the whole
row plus a gap above the dock-relative 2vh + 5rem credit position. */ row plus a gap above the dock-relative 2vh + 5rem credit position. */
#intel-hud[data-variant='cyber'] .hud-bottom-left { #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 { #intel-hud[data-variant='cyber'] .hud-corner {
@@ -1647,7 +1647,7 @@
below its 650px threshold. Keep the left rail inside the same telemetry below its 650px threshold. Keep the left rail inside the same telemetry
and attribution-safe floor while its panel bodies take over scrolling. */ and attribution-safe floor while its panel bodies take over scrolling. */
:root[data-ui-theme='cyber'] #left-panel-stack { :root[data-ui-theme='cyber'] #left-panel-stack {
bottom: calc(2vh + 13rem); bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
} }
} }
@@ -1670,7 +1670,7 @@
/* The stack holds still, so its rail needs no offset (leftPanelRail.js). */ /* The stack holds still, so its rail needs no offset (leftPanelRail.js). */
--panel-stack-top-pinned: 1; --panel-stack-top-pinned: 1;
top: var(--cyber-left-rail-top) !important; 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)); inset-inline-start: calc(54px + var(--panel-rail-clearance));
width: 300px; width: 300px;
padding-inline-start: 10px; 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. */ /* The dock's tray rules size voice by this basis; a column makes it a height. */
--dock-voice-width: auto; --dock-voice-width: auto;
top: auto; top: auto;
bottom: calc(var(--mobile-tab-space) + 10px); bottom: calc(var(--mobile-tab-space) + 10px + var(--world-timeline-lift, 0px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; 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) body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
#cesium-credits { #cesium-credits {
right: auto; 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; left: 10px;
/* With the base rule's nowrap the credits run off the right edge, and a /* 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 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. */ /* The view switcher clears the tab bar and the voice bar, opposite the credit. */
body.mobile-shell:not(.cockpit-mode) #view-switcher { body.mobile-shell:not(.cockpit-mode) #view-switcher {
right: 12px; 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 { 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 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 and the 12px of clear air holds regardless of height; the rail already
scrolls (overflow-y below), so it loses reach, not content. */ 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; left: 16px;
width: auto; width: auto;
max-height: none; max-height: none;
+1 -1
View File
@@ -390,7 +390,7 @@ html:not([lang='en']) #global-loading-detail {
/* ── Toast Notification ──────────────────────── */ /* ── Toast Notification ──────────────────────── */
#toast { #toast {
position: fixed; position: fixed;
bottom: 100px; bottom: calc(100px + var(--world-timeline-lift, 0px));
left: 50%; left: 50%;
transform: translateX(-50%) translateY(20px); transform: translateX(-50%) translateY(20px);
z-index: 200; 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 AwarenessDirection from './panels/AwarenessDirection.vue';
import UiButton from '../kit/UiButton.vue'; import UiButton from '../kit/UiButton.vue';
import GlobeActions from './panels/GlobeActions.vue'; import GlobeActions from './panels/GlobeActions.vue';
import WorldTimeline from './panels/WorldTimeline.vue';
// The ACTIVE STYLE corner shows the style VisualSettings publishes. // The ACTIVE STYLE corner shows the style VisualSettings publishes.
const { style } = storeToRefs(useDisplayStore()); const { style } = storeToRefs(useDisplayStore());
@@ -117,4 +118,5 @@ const vToastText = {
<MissionReplayOverlay /> <MissionReplayOverlay />
<ScreenAnnotations /> <ScreenAnnotations />
<AwarenessDirection /> <AwarenessDirection />
<WorldTimeline />
</template> </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(), []); assert.deepEqual(methods.getAnalystRecords(), []);
}); });
test('launches carry time and hours until launch', () => { test('launches carry time and hours until launch at the world time', () => {
const at = Date.now() + 5 * 3_600_000; const now = Date.UTC(2026, 10, 1);
const at = now + 5 * 3_600_000;
const state = { const state = {
now: () => now,
_enabled: true, _enabled: true,
_launches: [ _launches: [
{ {
+1
View File
@@ -29,6 +29,7 @@
@import './src/ui/styles/provider-settings.css'; @import './src/ui/styles/provider-settings.css';
@import './src/ui/styles/locale-picker.css'; @import './src/ui/styles/locale-picker.css';
@import './src/ui/styles/weather.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/panel-surfaces.css';
@import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/segmented-control.css';
@import './src/ui/styles/kit.css'; @import './src/ui/styles/kit.css';