From 0c4da3cdb27c0eda490954d8581079809cf3ae32 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:47:32 -0800 Subject: [PATCH 1/8] 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. --- package.json | 1 + scripts/package-boundaries.json | 40 ++- src/app/constructCatalog.js | 3 +- src/app/layers/gnss.js | 6 +- src/app/layers/recentImagery.js | 4 + src/app/scene.js | 8 + src/celestialRing.js | 15 +- src/hud.js | 13 +- src/layers/flights/motion.js | 5 +- src/layers/flights/motion.test.mjs | 14 +- src/layers/flights/rendering.js | 3 +- src/layers/flights/state.js | 3 + src/layers/flights/tracking.js | 3 +- src/layers/gnss/index.js | 4 +- src/layers/launches/launchPad.js | 2 +- src/layers/launches/model.js | 2 +- src/layers/launches/orbitRendering.js | 2 +- src/layers/launches/rendering.js | 4 +- src/layers/launches/state.js | 9 + src/layers/military/motion.js | 5 +- src/layers/military/state.js | 3 + src/layers/military/tracking.js | 3 +- src/layers/satellites/catalog.js | 4 +- src/layers/satellites/controls.js | 5 +- src/layers/satellites/ingestion.js | 2 +- src/layers/satellites/ingestion.test.mjs | 1 + src/layers/satellites/lifecycle.js | 26 +- src/layers/satellites/orbits.js | 6 +- src/layers/satellites/policy.js | 2 + src/layers/satellites/records.js | 2 +- src/layers/satellites/rendering.js | 6 +- src/layers/satellites/state.js | 5 + src/layers/satellites/tracking.js | 7 +- src/layers/streetLevel/index.js | 8 +- src/layers/transit/lifecycle.test.mjs | 1 + src/layers/transit/rendering.js | 12 +- src/layers/transit/state.js | 3 + src/layers/weather/clock.js | 127 ++++++++-- src/layers/weather/clock.test.mjs | 176 +++++++++++++ src/time/julian.js | 13 + src/time/viewerClock.js | 46 ++++ src/time/worldClock.js | 308 +++++++++++++++++++++++ src/time/worldClock.test.mjs | 254 +++++++++++++++++++ 43 files changed, 1091 insertions(+), 75 deletions(-) create mode 100644 src/time/julian.js create mode 100644 src/time/viewerClock.js create mode 100644 src/time/worldClock.js create mode 100644 src/time/worldClock.test.mjs diff --git a/package.json b/package.json index 7dda05144..44debccb2 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "./infrastructure/lod": "./src/data/localGeojsonLod.js", "./application": "./src/app/application.js", "./application/viewer": "./src/app/viewer.js", + "./time": "./src/time/worldClock.js", "./build/vite": { "node": "./build/vite.js" }, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c8783bde9..b6ba070e2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -145,10 +145,16 @@ "src/ui/views/panels/GlobeCompass.vue", "src/ui/stores/recording.js", "src/ui/styleParameters.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/time/worldClock.js" ], "external": ["vue", "pinia", "vue-i18n"] }, + "time": { + "exports": ["./time"], + "modules": ["src/time/worldClock.js"], + "external": [] + }, "viewer": { "exports": ["./application/viewer"], "modules": [ @@ -1308,7 +1314,9 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1363,7 +1371,9 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": ["cesium"] }, @@ -1547,7 +1557,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/ui/unitLabels.js" + "src/ui/unitLabels.js", + "src/time/worldClock.js" ], "external": ["cesium", "satellite.js"] }, @@ -1587,7 +1598,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium"] }, @@ -1904,7 +1916,8 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/layers/streetLevel/providers/mapillary/policy.js" + "src/layers/streetLevel/providers/mapillary/policy.js", + "src/time/worldClock.js" ], "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] }, @@ -3138,7 +3151,10 @@ "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.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" ], "external": [ "hls.js", @@ -3734,7 +3750,9 @@ "src/layers/wind/inspectionMarker.js", "src/ui/stores/bhoteKoshiPanel.js", "src/hudReadouts.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/time/worldClock.js", + "src/time/julian.js" ], "external": [ "hls.js", @@ -4389,7 +4407,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium", "egm96-universal", "pbf"] }, @@ -4440,7 +4459,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": ["cesium", "pbf"] }, diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 198f91dff..7ffc08fa3 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,5 +1,6 @@ import { resolveCatalogSources } from './sourceComposition.js'; import { createWeatherClock } from '../layers/weather/clock.js'; +import { worldClock } from '../time/worldClock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createWindLayer } from '../layers/wind/index.js'; @@ -72,7 +73,7 @@ export function createApplicationCatalog({ const sourceComposition = resolveCatalogSources(sources); sources = sourceComposition.sources; const militaryRegistry = createMilitaryRegistry(); - const weatherClock = createWeatherClock(); + const weatherClock = createWeatherClock({ world: worldClock }); const dispose = () => { signal.removeEventListener('abort', dispose); militaryRegistry.dispose(); diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 46983953b..72071e86a 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,6 +1,10 @@ import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; +import { worldTimeFor } from '../../time/worldClock.js'; /** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ export function createApplicationGnssIntegrity(options) { - return createGnssIntegrityLayer(options); + return createGnssIntegrityLayer({ + worldNow: worldTimeFor('gnss-integrity'), + ...options, + }); } diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index cc4659727..6a730bc7c 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js'; import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js'; import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; import { governorRequestRender } from '../../renderGovernor.js'; +import { worldTimeFor } from '../../time/worldClock.js'; + +const imageryTime = worldTimeFor('recent-imagery'); /** Construct imagery presentation over one explicitly supplied acquisition source. */ export function createApplicationRecentImagery({ source }) { @@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) { thumbnails: createThumbnailLoader({ requestThumbnail: (...args) => source.getThumbnail(...args), }), + now: () => new Date(imageryTime()), }); } diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..50695f68c 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -14,6 +14,8 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { worldClock, worldTimeFor } from '../time/worldClock.js'; +import { followWorldTime } from '../time/viewerClock.js'; import { onLocaleChange, t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ @@ -58,6 +60,12 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer( + followWorldTime(viewer, worldTimeFor('sky'), { + subscribe: worldClock.subscribe, + requestRender: () => governorRequestRender('world-time'), + }), + ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/celestialRing.js b/src/celestialRing.js index c031593d4..f470e7d18 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -1,5 +1,9 @@ import * as Cesium from 'cesium'; import { governorRequestRender } from './renderGovernor.js'; +import { worldClock, worldTimeFor } from './time/worldClock.js'; + +// The ring shows the sun and moon at the time the sky is drawn. +const skyTime = worldTimeFor('sky'); /** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */ export const KEYHOLE_OUTER_RADIUS = 1.05; @@ -445,6 +449,12 @@ export class CelestialRing { if (typeof document !== 'undefined' && document.hidden) return; governorRequestRender('celestial-ephemeris'); }, 60_000); + // A jump in the world time moves the sun and moon at once. + this._skyDate = new Date(); + this._skyTime = new Cesium.JulianDate(); + this._unsubscribeWorldTime = worldClock.subscribe(() => { + this._ephemerisDirty = true; + }); this.setEnabled(this.enabled); } @@ -801,7 +811,8 @@ export class CelestialRing { return; } - const time = Cesium.JulianDate.now(); + this._skyDate.setTime(skyTime()); + const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime); if (!this._updateEphemeris(time)) return; this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) }); const camera = this.viewer.camera; @@ -890,6 +901,8 @@ export class CelestialRing { clearInterval(this._ephemerisTimer); this._ephemerisTimer = null; } + this._unsubscribeWorldTime?.(); + this._unsubscribeWorldTime = null; if (this._removePostRender) this._removePostRender(); this._removePostRender = null; if (this._shown()) { diff --git a/src/hud.js b/src/hud.js index 80ef5e6f8..d74b05878 100644 --- a/src/hud.js +++ b/src/hud.js @@ -18,6 +18,11 @@ import * as Cesium from 'cesium'; import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; import { CITY_POIS } from './cityPois.js'; +import { worldTimeFor } from './time/worldClock.js'; + +// The HUD stamps its readouts with the world time; its sun is the sky's. +const hudTime = worldTimeFor('hud'); +const skyTime = worldTimeFor('sky'); import { composeLocalityTag } from './hudLocality.js'; import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js'; import { @@ -385,11 +390,11 @@ export class IntelHUD { } /** - * Format the current wall-clock time as a UTC Zulu string. + * Format the HUD's world time as a UTC Zulu string. * @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format. */ _formatUTC() { - const now = new Date(); + const now = new Date(hudTime()); const y = now.getUTCFullYear(); const mo = String(now.getUTCMonth() + 1).padStart(2, '0'); const d = String(now.getUTCDate()).padStart(2, '0'); @@ -491,7 +496,7 @@ export class IntelHUD { readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; // Collection timestamp - const now = new Date(); + const now = new Date(hudTime()); const h = String(now.getUTCHours()).padStart(2, '0'); const m = String(now.getUTCMinutes()).padStart(2, '0'); const s = String(now.getUTCSeconds()).padStart(2, '0'); @@ -598,7 +603,7 @@ export class IntelHUD { * @returns {number} Estimated sun elevation in degrees (negative = below horizon). */ _estimateSunElevation(lat, lon) { - const now = new Date(); + const now = new Date(skyTime()); // Approximate local solar time by shifting UTC hours by longitude offset const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15; const solarNoon = 12; diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index b66775f1e..a14c8c4ac 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { COURSE_HOLD_SPEED_MPS, lerpAngleDeg, @@ -156,7 +157,7 @@ export function createMotion({ const renderTime = sampledRenderTime ?? Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(flightState._scratchRenderTime), + julianAt(flightState.now(), flightState._scratchRenderTime), -RENDER_DELAY_SEC, flightState._scratchRenderTime, ); @@ -332,7 +333,7 @@ export function createMotion({ const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/flights/motion.test.mjs b/src/layers/flights/motion.test.mjs index dcb809d86..dd7a0ab03 100644 --- a/src/layers/flights/motion.test.mjs +++ b/src/layers/flights/motion.test.mjs @@ -12,6 +12,7 @@ function fixture() { _scratchArc: {}, _scratchOffset: new Cesium.Cartesian3(), _scratchRenderTime: new Cesium.JulianDate(), + now: Date.now, }; const motion = createMotion({ flightState, @@ -138,7 +139,7 @@ test('fleet render time is shared without resampling or mutating the supplied in assert.ok(Cesium.JulianDate.equals(renderTime, savedTime)); }); -test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => { +test('tracked callers still sample a fresh delayed time without a fleet epoch', () => { const { flightState, motion } = fixture(); const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z'); const history = [ @@ -156,10 +157,11 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch', flightState.records.data.set('tracked', { velocity: 100, true_track: 90 }); flightState._positionHistory.set('tracked', history); let elapsed = 60; - const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => { - assert.equal(result, flightState._scratchRenderTime); - return Cesium.JulianDate.addSeconds(start, elapsed, result); - }); + let reads = 0; + flightState.now = () => { + reads += 1; + return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000; + }; const out = new Cesium.Cartesian3(); close( motion._deadReckon('tracked', out), @@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch', new Cesium.Cartesian3(), ), ); - assert.equal(spy.mock.callCount(), 2); + assert.equal(reads, 2); }); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index d4075a565..f14870590 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { civilAircraftModelSpec } from './modelSpec.js'; @@ -863,7 +864,7 @@ export function createRendering({ flightState._lastFleetTickMs = nowMs; // Every contact in this pass shares the same delayed display instant. const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(flightState._scratchFleetRenderTime), + julianAt(flightState.now(), flightState._scratchFleetRenderTime), -RENDER_DELAY_SEC, flightState._scratchFleetRenderTime, ); diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index b297d5a9d..d87eaf880 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -1,6 +1,7 @@ import { FlightRecords } from './records.js'; import { createFlightFeed } from './ingestion.js'; import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js'; export function createFlightState({ source, services }) { @@ -12,6 +13,8 @@ export function createFlightState({ source, services }) { ...services.groundFloor, }); flightState._cullPositions = new Map(); + /** The time contacts are drawn behind by the render delay, in epoch ms. */ + flightState.now = worldTimeFor('flights'); /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs * vertex-baked to real-world METERS in the airplane.glb axis convention, so diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index dde8f4f95..c9569a914 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { formatNumber, t } from '../../i18n/index.js'; import { unitLabel } from '../../ui/unitLabels.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; @@ -382,7 +383,7 @@ export function createTracking({ // would draw the trail in front of the plane. They join the trail via _appendTrailFix // as they age past the delay. const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index ba0e9ff5d..7a9087998 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({ source, viewAnchor = defaultGnssViewAnchor, now = () => Date.now(), + // The end of the window shown; retries and receipt times stay on `now`. + worldNow = now, monotonicNow = () => performance.now(), windowMs: requestedWindowMs, } = {}) { @@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({ /** Age the store without new evidence, so expired cells leave the globe. */ function expire() { - pruneGnssObservations(_observations, now(), { windowMs }); + pruneGnssObservations(_observations, worldNow(), { windowMs }); _cells = binGnssCells(_observations); render(); } diff --git a/src/layers/launches/launchPad.js b/src/layers/launches/launchPad.js index f0a87ce6a..9fd438762 100644 --- a/src/layers/launches/launchPad.js +++ b/src/layers/launches/launchPad.js @@ -105,7 +105,7 @@ export function createLaunchPad({ } const nowMs = Date.now(); if (nowMs - layerState._lastMissionRingRotationMs >= 1000) { - layerState._missionRingDate.setTime(nowMs); + layerState._missionRingDate.setTime(layerState.orbitNow()); parts.orbitRendering.updateMissionOrbitPrimitiveFrames( layerState._missionRingDate, ); diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js index 5697e07e8..f9d712246 100644 --- a/src/layers/launches/model.js +++ b/src/layers/launches/model.js @@ -175,7 +175,7 @@ export function createModel({ state: layerState, services, parts, source }) { * @returns {Array} */ - function normalizeRocketLaunches(payload, now = new Date()) { + function normalizeRocketLaunches(payload, now = new Date(layerState.now())) { const launches = Array.isArray(payload) ? payload : payload?.results; if (!Array.isArray(launches)) return []; const cutoff = now.getTime() - WINDOW_DAYS * 86400000; diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js index d907904eb..b9a6cab09 100644 --- a/src/layers/launches/orbitRendering.js +++ b/src/layers/launches/orbitRendering.js @@ -124,7 +124,7 @@ export function createOrbitRendering({ collection.show = missionOrbitPrimitiveVisible(launch.id); orbitFrameModelMatrix( satelliteTrack.gmstAtBake, - new Date(), + new Date(layerState.orbitNow()), collection.modelMatrix, ); layerState._viewer.scene.primitives.add(collection); diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js index 6379e78e1..52eb39961 100644 --- a/src/layers/launches/rendering.js +++ b/src/layers/launches/rendering.js @@ -394,7 +394,7 @@ export function createRendering({ orbitCurrent.latitude, orbitCurrent.altitude, ); - const liveTime = new Date(); + const liveTime = new Date(layerState.orbitNow()); const liveTelemetry = { speedMps: null }; layerState._satelliteTelemetry.set(launch.id, liveTelemetry); let liveFrameNumber = -1; @@ -405,7 +405,7 @@ export function createRendering({ layerState._viewer?.scene?.frameState?.frameNumber ?? -1; if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; liveFrameNumber = frameNumber; - const nowMs = Date.now(); + const nowMs = layerState.orbitNow(); liveTime.setTime(nowMs); if (satelliteTrack) { const propagated = diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index b2c4d266b..469013da7 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -147,6 +148,14 @@ export function createState({ services }) { state._missionRingDate = new Date(0); + /** The time the launch window ends at, in epoch ms. */ + + state.now = worldTimeFor('rocket-launches'); + + /** A launched mission's satellite flies on the satellites layer's time. */ + + state.orbitNow = worldTimeFor('satellites'); + state._missionOrbitPatternRegistered = false; state._pathDistanceCache = new WeakMap(); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 27489400b..a13508e54 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { lerpAngleDeg, speedRamp, @@ -67,7 +68,7 @@ export function createMotion({ // Render one poll interval behind real time so we interpolate between // two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale). const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchRenderTime, ); @@ -213,7 +214,7 @@ export function createMotion({ const history = flightState._positionHistory.get(flightState._trackedIcao); if (!history || history.length === 0) return true; const renderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/military/state.js b/src/layers/military/state.js index f1a2d691f..f27595d2b 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -1,6 +1,7 @@ import { MilitaryFlightRecords } from './records.js'; import { createMilitaryFeed } from './ingestion.js'; import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; export function createFlightState({ source, services }) { const { createGroundSnap } = services.groundSnap; @@ -11,6 +12,8 @@ export function createFlightState({ source, services }) { ...services.groundFloor, }); flightState._cullPositions = new Map(); + /** The time contacts are drawn behind by the render delay, in epoch ms. */ + flightState.now = worldTimeFor('military'); /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: * military contacts should read as their WEIGHT CLASS, always in this layer's diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 8c9723176..d90d43c35 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { julianAt } from '../../time/julian.js'; import { t } from '../../i18n/index.js'; import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js'; import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js'; @@ -499,7 +500,7 @@ export function createTracking({ // newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them // would draw the trail in front of the plane. They join via _appendTrailFix as they age. const seedRenderTime = Cesium.JulianDate.addSeconds( - Cesium.JulianDate.now(), + julianAt(flightState.now()), -RENDER_DELAY_SEC, flightState._scratchWarmupTime, ); diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 097d9e3be..c5c1aaa75 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { 1, Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES), ); - const now = new Date(); + const now = new Date(layerState.now()); for (let i = 0; i < perFrame; i++) { if (layerState._denseCursor >= layerState._denseIds.length) layerState._denseCursor = 0; @@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entries = parts.orbits.parseTLE(text); const style = POINT_STYLES.dense; - const now = new Date(); + const now = new Date(layerState.now()); let added = 0; for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index f3327b332..bb33a0c40 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -190,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) { if (noradId === null) return null; const sat = layerState._catalog.get(noradId); - const pos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const pos = parts.orbits.propagatePosition( + sat.satrec, + new Date(layerState.now()), + ); if (!pos) return null; return { diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 01653611c..3db7d7471 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -103,7 +103,7 @@ export function createIngestion({ layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog parts.labels._syncIssOverlay(); - const now = new Date(); + const now = new Date(layerState.now()); // Process all TLE entries in CATALOG_GROUPS order const allEntries = []; diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index 313d999ee..d08f8b7a8 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -32,6 +32,7 @@ function ingestionFor(catalogText) { _denseLoadToken: 0, _catalogRevision: 0, _params: { catalog: 'core', showOrbits: false }, + now: Date.now, }; const parts = { orbits: { diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 642a6879f..896f2fe96 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; -import { ISS_OVERLAY_SOURCE_ID } from './policy.js'; +import { ISS_OVERLAY_SOURCE_ID, SATELLITE_COVERAGE_MS } from './policy.js'; +import { worldClock } from '../../time/worldClock.js'; export function createLifecycle({ state: layerState, @@ -8,6 +9,26 @@ export function createLifecycle({ source, }) { const { clearFocusTarget } = services.focus; + // While on with a catalog, satellites can be shown at any time in reach of + // their element sets. + let releaseCoverage = null; + const coverWorldTime = () => { + releaseCoverage ??= worldClock.register({ + id: 'satellites', + coverage: () => { + if (!layerState._catalog.size) return null; + const now = worldClock.wallNow(); + return { + start: now - SATELLITE_COVERAGE_MS, + end: now + SATELLITE_COVERAGE_MS, + }; + }, + }); + }; + const uncoverWorldTime = () => { + releaseCoverage?.(); + releaseCoverage = null; + }; const { holdContinuousRender, releaseContinuousRender } = services.render; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -64,6 +85,7 @@ export function createLifecycle({ enable(viewer) { layerState._enabled = true; + coverWorldTime(); holdContinuousRender('satellites'); // per-frame animator (perf wave 2) if (layerState._pointCollection) layerState._pointCollection.show = @@ -98,6 +120,7 @@ export function createLifecycle({ parts.catalog._abortActiveUpdates(); parts.tracking._cancelPendingTrackingRestore(); layerState._enabled = false; + uncoverWorldTime(); releaseContinuousRender('satellites'); if (layerState._pointCollection) layerState._pointCollection.show = false; for (const path of layerState._orbitPaths.values()) @@ -125,6 +148,7 @@ export function createLifecycle({ parts.catalog._abortActiveUpdates(); releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) layerState._enabled = false; + uncoverWorldTime(); parts.tracking._clearTracking(); parts.tracking._cancelPendingTrackingRestore(); if (layerState._clickHandler) { diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 0e4461e85..37a56d106 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -133,7 +133,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = layerState.now(), }) { return getNextSatellitePass(ISS_NORAD, { latDeg, @@ -163,7 +163,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { lonDeg, minElevDeg = 10, requireVisible = false, - fromMs = Date.now(), + fromMs = layerState.now(), }, ) { const id = Number(noradId); @@ -280,7 +280,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { } function orbitTrackFromRecord(name, satrec) { - const referenceDate = new Date(); + const referenceDate = new Date(layerState.now()); const current = propagatePosition(satrec, referenceDate); if (!current) return null; return { diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js index e7ea5456d..a6bfef8fe 100644 --- a/src/layers/satellites/policy.js +++ b/src/layers/satellites/policy.js @@ -28,6 +28,8 @@ export const ORBIT_PATH_STEPS = 180; // points per orbital path export const POSITION_UPDATE_MS = 1000; +/** How far either side of now SGP4 on current element sets stays meaningful. */ +export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000; // re-propagate every 1s (SGP4 is smooth at this rate) export const RING_ROTATION_MS = 1000; diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index d4fb4d076..9f99d6b0d 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -79,7 +79,7 @@ export function createRecords({ state, parts }) { const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; - const now = new Date(); + const now = new Date(state.now()); const result = []; const emit = (noradId, sat) => { if (result.length >= limit) return false; diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js index 08e4396bc..e722ce7c2 100644 --- a/src/layers/satellites/rendering.js +++ b/src/layers/satellites/rendering.js @@ -42,7 +42,7 @@ export function createRendering({ const sat = layerState._catalog.get(noradId); if (!sat || !layerState._viewer) return; - const bakeDate = new Date(); + const bakeDate = new Date(layerState.now()); const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate); if (basePositions.length < 2) return; @@ -143,7 +143,7 @@ export function createRendering({ */ function _propagateAll() { - const now = new Date(); + const now = new Date(layerState.now()); let updated = 0; for (const [noradId, sat] of layerState._catalog) { @@ -211,7 +211,7 @@ export function createRendering({ layerState._params.showOrbits && now - layerState._lastRingRotation >= RING_ROTATION_MS ) { - _updateOrbitPathRotations(new Date(now)); + _updateOrbitPathRotations(new Date(layerState.now())); layerState._lastRingRotation = now; } } diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 50a127b54..44e004476 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -151,6 +152,10 @@ export function createState({ services }) { state._trackedFrameNowForTest = null; + /** The time satellites are propagated to, in epoch ms. */ + + state.now = worldTimeFor('satellites'); + // Scratch variables state._scratchCartesian = new Cesium.Cartesian3(); diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 2b2624ff7..7353d92c8 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -154,7 +154,7 @@ export function createTracking({ state: layerState, services, parts, source }) { sat.satrec, layerState._trackedFrameNowForTest ? new Date(layerState._trackedFrameNowForTest()) - : new Date(), + : new Date(layerState.now()), ); if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample layerState._trackedFrameGeo = pos; @@ -434,7 +434,10 @@ export function createTracking({ state: layerState, services, parts, source }) { // Comfortable tracking landing: ~726 km back for LEO (user-validated // "slightly zoomed out" framing — no stutter, label reads cleanly), scaled // up for MEO/GEO so the camera doesn't land on top of a high-orbit dot. - const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date()); + const initialPos = parts.orbits.propagatePosition( + sat.satrec, + new Date(layerState.now()), + ); const viewScale = initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M ? TRACK_VIEW_FROM_HIGH_SCALE diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 1f7b0f7f0..e7303523e 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -8,6 +8,7 @@ import { createSelection } from './selection.js'; import { createViewerHost } from './viewerHost.js'; import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js'; import { normalizeFilter, resolveFilter, sameFilter } from './filter.js'; +import { worldTimeFor } from '../../time/worldClock.js'; import { decodeParams, encodeParams } from './params.js'; import { composeUIState, @@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000; * a time. * @param {{providers: Array, services?: object}} options */ +// "Since N days" counts back from the world time. +const streetLevelTime = worldTimeFor(STREET_LEVEL_LAYER_ID); + export function createStreetLevelLayer({ providers: definitions, services = {}, @@ -93,7 +97,7 @@ export function createStreetLevelLayer({ return Object.freeze({ services: state.services, /** Resolved against now, so "since N days" keeps up with the clock. */ - getFilter: () => resolveFilter(state.filter), + getFilter: () => resolveFilter(state.filter, streetLevelTime()), /** The layer and this provider's switch are both on. */ isActive: () => state.enabled && entry.on, notify, @@ -356,7 +360,7 @@ export function createStreetLevelLayer({ const filter = normalizeFilter(next, state.filter); if (sameFilter(filter, state.filter)) return; state.filter = filter; - const resolved = resolveFilter(filter); + const resolved = resolveFilter(filter, streetLevelTime()); for (const entry of state.providers.values()) entry.instance.setFilter(resolved); notify(); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index ea547f85d..d1c14dd64 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -3927,6 +3927,7 @@ test('parked transit reprojects held and reported courses at the bounded camera _rotationRevision: -1, _cameraRevision: 0, _rotationDirty: false, + now: () => Date.now(), }; const noop = () => {}; const rendering = createRendering({ diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 2309978a5..f3868444a 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -108,7 +108,7 @@ export function createRendering({ state, services, parts }) { } function sampleIdle(entry) { if (!state._moving.has(entry)) { - updatePlayback(entry, Date.now(), performance.now()); + updatePlayback(entry, state.now(), performance.now()); placeSample(entry); } } @@ -172,7 +172,7 @@ export function createRendering({ state, services, parts }) { () => { entry.wakeTimer = null; if (!state._enabled || !state._vehicles.has(entry.key)) return; - updatePlayback(entry, Date.now(), performance.now()); + updatePlayback(entry, state.now(), performance.now()); placeSample(entry); schedulePlayback(entry); syncRenderHold(); @@ -388,7 +388,7 @@ export function createRendering({ state, services, parts }) { ) return; if (!state._enabled) return; - const now = Date.now(); + const now = state.now(); if (state._vehicles.size === 0) { state._moving.clear(); syncRenderHold(); @@ -424,7 +424,7 @@ export function createRendering({ state, services, parts }) { } parts.trails.update(); - refreshRotations(now); + refreshRotations(Date.now()); const selected = state._vehicles.get(state._selectedKey); if (selected && state._visible.has(selected)) { selected.marker.rotation = Number.isFinite(selected.courseDeg) @@ -489,7 +489,7 @@ export function createRendering({ state, services, parts }) { const noSample = !entry.sample || !Number.isFinite(entry.sample.lat); if (entry.track?.count && (!state._visible.has(entry) || noSample)) { entry.sample ||= {}; - updatePlayback(entry, now, performance.now()); + updatePlayback(entry, state.now(), performance.now()); } sphere.center = state._visible.has(entry) || !entry.sample @@ -557,7 +557,7 @@ export function createRendering({ state, services, parts }) { } function resumePlayback() { if (!state._enabled) return; - const now = Date.now(), + const now = state.now(), mono = performance.now(); const entries = new Set(state._visible); const selected = state._vehicles.get(state._selectedKey); diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 75b13d84b..5bb152015 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { createHistoryBudget } from '../../data/contactPlayback.js'; +import { worldTimeFor } from '../../time/worldClock.js'; /** * One Transit layer's mutable scene state. Every field lives on the instance, @@ -14,6 +15,8 @@ export function createState({ services }) { const state = {}; state._historyBudget = createHistoryBudget(); + /** The time vehicles play back behind by their display delay, in epoch ms. */ + state.now = worldTimeFor('transit'); state.DEFAULT_OVERLAY_HOST = Object.freeze({ setEntries: setOverlayEntries, diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index a758dddd2..9e2d5a96f 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -1,19 +1,35 @@ +import { createWorldClock } from '../../time/worldClock.js'; + export const RADAR_MAX_GAP_MS = 30 * 60_000; export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000; export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000; export const LIGHTNING_MAX_GAP_MS = 30 * 60_000; -/** Transient observed time. Products must honor the signal before installing a frame. */ +/** + * Transient observed time. Products must honor the signal before installing a + * frame. Its time is the world clock's: latest while the world is live, + * history at the world's time otherwise. Replay steps the observed frames and + * moves the world clock as it goes, as the world clock's driver. + */ export function createWeatherClock({ now = Date.now, setTimeout = globalThis.setTimeout, clearTimeout = globalThis.clearTimeout, + world = createWorldClock({ now }), } = {}) { const registry = new Map(); const listeners = new Set(); let mode = 'latest'; let target = null; let playing = false; + // The world time last applied; while this clock moves the world, the + // transition its move started. + let seen = 'latest'; + let moving = false; + let moved = null; + const releases = new Map(); + // Attached at the end, once the API exists. + let driver = null; let generation = 0; let timer = null; let pending = null; @@ -65,11 +81,32 @@ export function createWeatherClock({ timer = null; }; const canPlay = () => getTimeline().length >= 2; + const stopPlaying = () => { + if (!playing) return; + playing = false; + driver?.driven(false); + }; + // Move the world clock; the change comes back through followWorld. Returns + // the transition the move started, if it started one. + const move = (fn) => { + const outer = [moving, moved]; + moving = true; + moved = null; + try { + fn(); + return moved; + } finally { + [moving, moved] = outer; + } + }; + const selections = () => + [...registry.values()].map(({ product }) => selectedFor(product)).join(); + let applied = null; function schedule() { cancelTimer(); if (!playing || destroyed || pending) return; if (!canPlay()) { - playing = false; + stopPlaying(); notify(); return; } @@ -92,7 +129,8 @@ export function createWeatherClock({ if (destroyed) return Promise.resolve(false); cancelTimer(); const owner = ++generation; - if (!canPlay()) playing = false; + if (!canPlay()) stopPlaying(); + applied = selections(); for (const entry of registry.values()) entry.controller?.abort(); // Start all products together; a rejection must not skip the settle barrier. const tasks = [...registry.values()].map((entry) => { @@ -107,12 +145,13 @@ export function createWeatherClock({ const result = Promise.allSettled(tasks).then(() => { if (destroyed || owner !== generation) return false; pending = null; - if (!canPlay()) playing = false; + if (!canPlay()) stopPlaying(); notify(); schedule(); return true; }); pending = result; + if (moving) moved ??= result; notify(); return result; } @@ -123,11 +162,14 @@ export function createWeatherClock({ throw new Error(`Duplicate weather product: ${product.id}`); const entry = { product, controller: null }; registry.set(product.id, entry); + releases.set(product.id, world.hold(product.id)); void api.refresh(); return () => { if (registry.get(product.id) !== entry) return; entry.controller?.abort(); registry.delete(product.id); + releases.get(product.id)?.(); + releases.delete(product.id); void api.refresh(); }; }, @@ -140,9 +182,11 @@ export function createWeatherClock({ const normalized = new Date(time).toISOString(); if (mode === 'history' && target === normalized) return pending ?? Promise.resolve(true); - mode = 'history'; - target = normalized; - return transition(); + return ( + move(() => world.seek(Date.parse(normalized))) ?? + pending ?? + Promise.resolve(true) + ); }, step(direction) { if (destroyed || ![-1, 1].includes(direction)) @@ -159,10 +203,9 @@ export function createWeatherClock({ }, latest() { if (destroyed) return Promise.resolve(false); - mode = 'latest'; - target = null; - playing = false; - return transition(); + stopPlaying(); + if (mode === 'latest') return transition(); + return move(() => world.goLive()) ?? pending ?? Promise.resolve(true); }, play() { if (destroyed || playing || !canPlay()) @@ -170,18 +213,28 @@ export function createWeatherClock({ playing = true; if (mode === 'latest') { const timeline = getTimeline(); - return api.setTarget( - timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0], + const start = + timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0]; + // Playing and at the first frame in one move of the world. + return ( + move(() => driver.driven(true, Date.parse(start))) ?? + pending ?? + Promise.resolve(true) ); } + driver.driven(true); notify(); schedule(); return pending ?? Promise.resolve(true); }, pause() { cancelTimer(); - if (!playing) return; - playing = false; + if (!playing) { + // The world may be playing on its own; a pause or step here holds it. + if (world.isPlaying()) world.pause(); + return; + } + stopPlaying(); notify(); }, togglePlay() { @@ -190,7 +243,7 @@ export function createWeatherClock({ refresh() { if (destroyed) return Promise.resolve(false); if (!canPlay()) { - playing = false; + stopPlaying(); cancelTimer(); } if (mode === 'history') return transition(); @@ -204,15 +257,55 @@ export function createWeatherClock({ }, destroy() { if (destroyed) return; + stopPlaying(); destroyed = true; ++generation; - playing = false; cancelTimer(); + unfollowWorld(); + uncover(); + // The last weather clock leaves the world live, as a new one expects. + if (driver.detach() === 0) world.goLive(); + for (const release of releases.values()) release(); + releases.clear(); for (const entry of registry.values()) entry.controller?.abort(); registry.clear(); listeners.clear(); pending = null; }, }; + // Follow the world clock. A move of this clock's own always applies; one + // from elsewhere (a host's seek, continuous play) applies only when it + // changes the mode or a product's frame. + function followWorld(state) { + if (destroyed) return; + const nextMode = state.live ? 'latest' : 'history'; + const nextTarget = state.live + ? null + : new Date(Math.floor(state.timeMs)).toISOString(); + const key = nextTarget ?? 'latest'; + if (key === seen) return; + seen = key; + const modeChanged = nextMode !== mode; + mode = nextMode; + target = nextTarget; + if (moving || modeChanged || selections() !== applied) void transition(); + else notify(); + } + const unfollowWorld = world.subscribe(followWorld); + // The observed frames this clock can show, for a host's timeline. + const uncover = world.register({ + id: 'weather', + coverage: () => { + const marks = getTimeline().map((time) => Date.parse(time)); + return marks.length + ? { start: marks[0], end: marks.at(-1), marks } + : null; + }, + }); + driver = world.attachDriver({ + play: () => (canPlay() ? void api.play() : false), + pause: () => api.pause(), + }); + followWorld(world.getState()); return api; } diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 24bd124b8..9561c10c7 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -7,6 +7,7 @@ import { GLOBAL_INFRARED_MAX_GAP_MS, LIGHTNING_MAX_GAP_MS, } from './clock.js'; +import { createWorldClock } from '../../time/worldClock.js'; const time = (minutes) => new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString(); @@ -37,7 +38,15 @@ function harness(t) { let at = Date.parse(time(20)); const timers = new Map(); let id = 0; + let mono = 0; + const world = createWorldClock({ + now: () => at, + monotonicNow: () => mono, + setInterval: () => 0, + clearInterval() {}, + }); const clock = createWeatherClock({ + world, now: () => at, setTimeout(fn, delay) { timers.set(++id, { fn, due: at + delay }); @@ -50,9 +59,11 @@ function harness(t) { t.after(() => clock.destroy()); return { clock, + world, timers, tick(ms) { at += ms; + mono += ms; for (const [id, timer] of [...timers]) { if (timer.due > at) continue; timers.delete(id); @@ -275,3 +286,168 @@ test('registration joins history; unregister and destroy abort work and release assert.deepEqual(clock.getTimeline(), []); assert.equal(await clock.setTarget(time(5)), false); }); + +test('the weather time is the world time, both ways', async (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + assert.equal(world.follows('radar'), true); + await clock.setTarget(time(10)); + assert.equal(world.isLive(), false); + assert.equal(world.now(), Date.parse(time(10))); + world.seek(Date.parse(time(3))); + await flush(); + assert.equal(clock.getState().target, time(3)); + assert.equal(radar.shown, time(0)); + world.goLive(); + await flush(); + assert.equal(clock.getState().mode, 'latest'); + assert.equal(radar.shown, time(20)); +}); + +test('world play and pause drive the weather replay', async (t) => { + const { clock, world, tick } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(0))); + await flush(); + world.play(); + assert.equal(clock.getState().playing, true); + assert.equal(world.isPlaying(), true); + tick(2000); + await flush(); + assert.equal(radar.shown, time(10)); + assert.equal(world.now(), Date.parse(time(10))); + world.pause(); + assert.equal(clock.getState().playing, false); + clock.play(); + assert.equal(world.isPlaying(), true); + clock.pause(); + assert.equal(world.isPlaying(), false); + clock.play(); + world.goLive(); + await flush(); + assert.equal(clock.getState().playing, false); + assert.equal(clock.getState().mode, 'latest'); +}); + +test('a moving world re-applies frames only when a product changes frame', async (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + world.seek(Date.parse(time(0))); + await flush(); + const applied = radar.calls.length; + world.seek(Date.parse(time(0)) + 60_000); + await flush(); + assert.equal(radar.calls.length, applied); + assert.equal( + clock.getState().target, + new Date(Date.parse(time(1))).toISOString(), + ); + world.seek(Date.parse(time(11))); + await flush(); + assert.equal(radar.calls.length, applied + 1); + assert.equal(radar.shown, time(10)); +}); + +test('a world without frames to step plays on its own', async (t) => { + const { clock, world, tick } = harness(t); + world.seek(Date.parse(time(0))); + world.play(); + assert.equal(clock.getState().playing, false); + tick(1000); + assert.equal(world.now(), Date.parse(time(0)) + 1000); +}); + +function worldAt(at) { + return createWorldClock({ + now: () => at, + monotonicNow: () => 0, + setInterval: () => 0, + clearInterval() {}, + }); +} + +test('weather clocks sharing a world hand play over, and the last leaves it live', async () => { + const world = worldAt(Date.parse(time(20))); + const a = createWeatherClock({ world, now: () => Date.parse(time(20)) }); + const b = createWeatherClock({ world, now: () => Date.parse(time(20)) }); + const radarA = product('radar', [0, 10, 20]); + const radarB = product('radar', [0, 10, 20]); + a.register(radarA); + b.register(radarB); + await a.play(); + assert.equal(world.isPlaying(), true); + world.pause(); + assert.equal(a.getState().playing, false); + assert.equal(world.isPlaying(), false); + await a.play(); + world.goLive(); + assert.equal(a.getState().playing, false); + b.destroy(); + assert.equal(world.follows('radar'), true); + world.seek(Date.parse(time(0))); + world.play(); + assert.equal(a.getState().playing, true); + a.destroy(); + assert.equal(world.follows('radar'), false); + assert.equal(world.isLive(), true); + assert.equal(world.isPlaying(), false); +}); + +test('pausing from inside the first playing notice stops the world too', async (t) => { + const { clock, world } = harness(t); + clock.register(product('radar', [0, 10, 20])); + const off = clock.subscribe((state) => { + if (state.playing) { + off(); + clock.pause(); + } + }); + await clock.play(); + assert.equal(clock.getState().playing, false); + assert.equal(world.isPlaying(), false); +}); + +test('a step holds a world that was playing on its own', async (t) => { + const { clock, world } = harness(t); + world.seek(Date.parse(time(0))); + world.play(); + clock.register(product('radar', [0, 10, 20])); + await clock.step(1); + assert.equal(world.isPlaying(), false); + assert.equal(world.now(), Date.parse(time(10))); +}); + +test('a target superseded from a listener resolves false', async (t) => { + const { clock } = harness(t); + const radar = product('radar', [0, 10, 20]); + clock.register(radar); + await flush(); + let redirected = false; + clock.subscribe((state) => { + if (state.target === time(0) && !redirected) { + redirected = true; + void clock.setTarget(time(10)); + } + }); + assert.equal(await clock.setTarget(time(0)), false); + assert.equal(radar.shown, time(10)); +}); + +test('the observed frames are the world clock coverage, while registered', (t) => { + const { clock, world } = harness(t); + assert.equal(world.coverage(), null); + 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))), + }); + unregister(); + clock.destroy(); + assert.equal(world.coverage(), null); +}); diff --git a/src/time/julian.js b/src/time/julian.js new file mode 100644 index 000000000..b63a53621 --- /dev/null +++ b/src/time/julian.js @@ -0,0 +1,13 @@ +import * as Cesium from 'cesium'; + +const scratch = new Date(); + +/** + * Epoch ms as a Cesium JulianDate, like `JulianDate.now(result)` for any time. + * @param {number} ms + * @param {Cesium.JulianDate} [result] + */ +export function julianAt(ms, result) { + scratch.setTime(ms); + return Cesium.JulianDate.fromDate(scratch, result); +} diff --git a/src/time/viewerClock.js b/src/time/viewerClock.js new file mode 100644 index 000000000..e4c456d42 --- /dev/null +++ b/src/time/viewerClock.js @@ -0,0 +1,46 @@ +import * as Cesium from 'cesium'; + +/** + * Draw Cesium's sky at the world time: the sun, moon, stars, atmosphere and + * the lighting on the tiles all read `frameState.time`, which Cesium takes + * from its own clock. That clock never animates in GEV, so before this the + * sky stayed at the moment the page loaded. + * + * Each frame's time is set in `preUpdate`, before the scene stamps the frame + * with it, so the sky is drawn at the same moment the layers read that frame. + * The clock's `currentTime` follows too, for anything that samples it. + * @param {Cesium.Viewer} viewer + * @param {() => number} readTime Epoch ms the sky is drawn at. + * @param {{subscribe?: Function, requestRender?: () => void}} [options] + * A world clock to hear moves from, so a resting scene repaints when the + * world time jumps. + * @returns {() => void} Stop following. + */ +export function followWorldTime( + viewer, + readTime, + { subscribe, requestRender = () => viewer.scene.requestRender() } = {}, +) { + const date = new Date(); + const removePreUpdate = viewer.scene.preUpdate.addEventListener( + (_scene, time) => { + date.setTime(readTime()); + const clock = viewer.clock; + Cesium.JulianDate.fromDate(date, clock.currentTime); + // The frame's own time: Cesium stamps the frame with this object next. + if (time) Cesium.JulianDate.clone(clock.currentTime, time); + }, + ); + let last = readTime(); + const unsubscribe = + subscribe?.(() => { + const next = readTime(); + if (next === last) return; + last = next; + requestRender(); + }) ?? (() => {}); + return () => { + removePreUpdate(); + unsubscribe(); + }; +} diff --git a/src/time/worldClock.js b/src/time/worldClock.js new file mode 100644 index 000000000..446318a43 --- /dev/null +++ b/src/time/worldClock.js @@ -0,0 +1,308 @@ +/** + * 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 + * (`follow(id)`) move with the cursor; the rest keep reading the live edge + * through `timeFor(id)`, so a host opts layers in one at a time. + * + * 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}} [options] + */ +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, +} = {}) { + const listeners = new Set(); + // Layers a host follows, and layers a part of the app holds following. + const following = new Set(); + 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)]), + ]; + 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(), + }); + // 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. + const notify = () => { + const round = ++notifying; + for (const listener of [...listeners]) { + if (round !== notifying) return; + if (!listeners.has(listener)) continue; + try { + listener(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); + return () => sources.delete(entry); + }, + /** 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 = source.coverage(); + if (!span || !(span.end >= span.start)) 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. */ +export const worldClock = createWorldClock(); + +/** The time layer `id` draws at on the page's clock. */ +export const worldTimeFor = (id) => worldClock.timeFor(id); diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs new file mode 100644 index 000000000..a954f60fd --- /dev/null +++ b/src/time/worldClock.test.mjs @@ -0,0 +1,254 @@ +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('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); +}); From 32ec6ed436498d7d0f0f60c15c93fe30aaeba118 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:50:09 -0800 Subject: [PATCH 2/8] 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. --- docs/SHARE-LINK-FORMAT.md | 5 +++++ scripts/package-boundaries.json | 9 ++++++--- src/layers/weather/index.js | 3 ++- src/share/viewCodec.js | 16 ++++++++++++++-- src/share/viewCodec.test.mjs | 21 +++++++++++++++++++++ src/sharelink.js | 13 +++++++++++-- src/sharelink.navigation.test.mjs | 25 +++++++++++++++++++++++++ src/ui/shareRestoration.js | 12 ++++++++++++ 8 files changed, 96 insertions(+), 8 deletions(-) diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 5728d1ec3..bf69653f9 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url). | 25 | `scopeFeatherPct` | uint | 100 | 0–200 | | 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | | 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | +| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z | Field 1 absent means `thing` when field 13 is present and `place` otherwise, so v1 never writes it; a later view kind writes its code. @@ -132,6 +133,10 @@ style also extends the pinned style-defaults table. `hudVariant` code 1 (`operator`) is retired: the app no longer offers that layout and restores it as `tactical`. The code stays reserved. +Field 28 is the world time the link was made at, written only when it was away +from live. A link without it opens live. Unlike field 8 it may lie ahead of the +link's creation (a forecast moment), and it opens paused at that time. + Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b6ba070e2..00f369e40 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1105,7 +1105,8 @@ "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", "src/ui/stores/recording.js", - "src/ui/i18n.js" + "src/ui/i18n.js", + "src/time/worldClock.js" ], "external": ["cesium", "vue", "pinia", "vue-i18n"] }, @@ -3890,7 +3891,8 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/time/worldClock.js" ], "external": [] }, @@ -4580,7 +4582,8 @@ "src/data/detectionPolicy.js", "src/scopeMaskPolicy.js", "src/i18n/marker.js", - "src/visualStylePolicy.js" + "src/visualStylePolicy.js", + "src/time/worldClock.js" ], "external": [] }, diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index e318e3087..4ccfdef6e 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -52,7 +52,8 @@ const utc = (value) => : t('Unavailable'); /** A per-application observation layer, using the existing layer lifecycle and - * row controls. History is transient: shared links always open latest imagery. */ + * row controls. Its time is the world clock's, so a link made away from live + * opens its imagery at that time; one made live opens the latest. */ export function createWeatherLayer({ feed, clock, diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index b72f79d34..5b54d8f2d 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -31,6 +31,7 @@ import { } from '../data/detectionPolicy.js'; import { clampScopeFeatherPct } from '../scopeMaskPolicy.js'; import { VIEW_STYLES } from '../view/index.js'; +import { isWorldTime } from '../time/worldClock.js'; import { RETIRED_STYLES } from '../visualStylePolicy.js'; import { GLOBAL_POST_DEFAULTS, @@ -373,6 +374,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([ { number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 }, { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, + { number: 28, key: 'worldTime', type: 'epoch', default: null }, ]); const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; @@ -732,6 +734,9 @@ export function normalizeShareViewState(input = {}) { : null; state.panelState = normalizePanelState(source.panelState); state.sharedAtMs = epochMsOr(source.sharedAtMs); + state.worldTime = isWorldTime(source.worldTime) + ? Math.floor(source.worldTime / 1000) * 1000 + : null; let view = normalizeView(source.view); if (state.layerState) view = liftTrackedEntity(state.layerState, view); if (view.kind === 'thing') { @@ -948,8 +953,9 @@ export function encodeShareView(input) { writer.sintField(field.number, signedAngle(state[field.key])); break; case 'sharedAtMs': - if (state.sharedAtMs !== null) - writer.sintField(field.number, epochSeconds(state.sharedAtMs)); + case 'worldTime': + if (state[field.key] !== null) + writer.sintField(field.number, epochSeconds(state[field.key])); break; case 'flags': { let bits = 0; @@ -1347,6 +1353,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) { raw.sharedAtMs = createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null; } + // The world time the link was made at, when it was away from live. It + // may lie ahead of the link's creation (a forecast) or of now. + if (get('worldTime')) { + const ms = (SHARE_VIEW_EPOCH_S + unz(get('worldTime').varint)) * 1000; + raw.worldTime = isWorldTime(ms) ? ms : null; + } if (get('layerState')) raw.layerState = decodeLayers( diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs index 6cc6fd9b1..f9fd81d38 100644 --- a/src/share/viewCodec.test.mjs +++ b/src/share/viewCodec.test.mjs @@ -361,6 +361,7 @@ test('every registered field round-trips at once', () => { })), }, sharedAtMs: NOW_MS, + worldTime: NOW_MS - 6 * 3_600_000, }; // A thing view derives its camera (roll 0), so the camera fields are // checked on the same state as a place view. @@ -501,6 +502,7 @@ test('the field registry is well formed and permanent', () => { [25, 'scopeFeatherPct', 100], [26, 'scopeTerminusPct', null], [27, 'styleParams', BY_STYLE], + [28, 'worldTime', null], ], ); assert.deepEqual( @@ -1338,3 +1340,22 @@ test('a layer option filed under a mirror layer is ignored', () => { { scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` }, ]); }); + +test('the world time is written only away from live, and may lie ahead', () => { + const at = (worldTime) => + decodeShareView(encodeShareView({ sharedAtMs: NOW_MS, worldTime }), { + nowMs: NOW_MS, + }).state.worldTime; + assert.equal(at(null), null); + assert.equal(at(NOW_MS - 3_600_000), NOW_MS - 3_600_000); + // A forecast moment ahead of the link's creation is kept. + assert.equal(at(NOW_MS + 86_400_000), NOW_MS + 86_400_000); + // Before the 2026 epoch too: the field is zigzag. + assert.equal(at(Date.UTC(2024, 0, 1)), Date.UTC(2024, 0, 1)); + // Past what a Date can hold: dropped, so the link opens live. + assert.equal(at(8.8e15), null); + assert.ok( + encodeShareView({ sharedAtMs: NOW_MS, worldTime: NOW_MS - 1000 }).length > + encodeShareView({ sharedAtMs: NOW_MS }).length, + ); +}); diff --git a/src/sharelink.js b/src/sharelink.js index 4a0291a84..fcef04ea5 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -5,6 +5,7 @@ import { normalizeAllocationStrategy, } from './data/detectionPolicy.js'; import { clampScopeTerminusPct } from './scopeMask.js'; +import { worldClock as pageWorldClock } from './time/worldClock.js'; import { clampScopeFeatherPct } from './scopeMaskPolicy.js'; import { decodeLayerStateParams, @@ -68,9 +69,11 @@ export class ShareLinkManager { isNavigationCurrent, cancelOwnedNavigation, initialShareCode = null, + worldClock = pageWorldClock, } = {}, ) { this.viewer = viewer; + this._worldClock = worldClock; // A host may hand over the share code to open instead of the address. this._initialShareCode = typeof initialShareCode === 'string' ? initialShareCode : null; @@ -163,8 +166,9 @@ export class ShareLinkManager { /** * The restore state for a share code, in the long form's parsed shape plus - * `shareView` (what the view follows, with its offset and last-known fix), - * or null when the code is refused. + * `shareView` (what the view follows, with its offset and last-known fix) + * and `worldTime` (null when the link was made live), or null when the + * code is refused. */ _parseShareCode(code) { const result = decodeShareView(code); @@ -174,6 +178,7 @@ export class ShareLinkManager { } const state = toLegacyShareState(result.state); state.shareView = result.state.view; + state.worldTime = result.state.worldTime; return state; } @@ -592,6 +597,10 @@ export class ShareLinkManager { // that state in the share format. const state = fromLegacyShareState(this._parseShareHash(params.toString())); state.sharedAtMs = Math.floor(nowMs / 1000) * 1000; + // Away from live, the link opens at the same world time. + state.worldTime = this._worldClock.isLive() + ? null + : Math.floor(this._worldClock.now() / 1000) * 1000; let subject = null; try { subject = this._followSubjectProvider?.({ nowMs }) ?? null; diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs index e4e70b629..e486ba58d 100644 --- a/src/sharelink.navigation.test.mjs +++ b/src/sharelink.navigation.test.mjs @@ -423,3 +423,28 @@ test('the layer coordinator replaces in-flight share layers with the navigated s ); coordinator.destroy(); }); + +test('a link made away from live carries its world time to the page that opens it', (t) => { + installTab(t, SHARE_A); + const worldTime = Date.UTC(2026, 9, 30, 6, 15, 42); + const sharer = new ShareLinkManager(fakeViewer(), { + worldClock: { isLive: () => false, now: () => worldTime + 400 }, + }); + const link = sharer.buildLink({ nowMs: Date.UTC(2026, 10, 1) }); + sharer.destroy(); + const code = new URL(link).pathname.split('/').at(-1); + const opener = new ShareLinkManager(fakeViewer(), { initialShareCode: code }); + assert.equal(opener.parseInitialHash().worldTime, worldTime); + opener.destroy(); + + const live = new ShareLinkManager(fakeViewer(), { + worldClock: { isLive: () => true, now: () => worldTime }, + }); + const liveCode = new URL(live.buildLink()).pathname.split('/').at(-1); + live.destroy(); + const liveOpener = new ShareLinkManager(fakeViewer(), { + initialShareCode: liveCode, + }); + assert.equal(liveOpener.parseInitialHash().worldTime, null); + liveOpener.destroy(); +}); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 12f09c18b..9fc4c6f45 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -4,6 +4,16 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { t, msg, formatDate } from '../i18n/index.js'; import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js'; import { UiLifetime } from './uiLifetime.js'; +import { isWorldTime, worldClock } from '../time/worldClock.js'; + +// A link made away from live opens at its world time, paused. One without a +// world time leaves the clock as it is: a page opens live, and Back or Forward +// through long-form hashes (which never carry one) does not stop a replay. +function applySharedWorldTime(state) { + if (!isWorldTime(state?.worldTime)) return; + worldClock.pause(); + worldClock.seek(state.worldTime); +} /** How long a shared vessel may take to appear in the vessel feed. */ export const SHARED_VESSEL_WAIT_MS = 120_000; @@ -146,6 +156,7 @@ export class ShareRestoration { : Promise.resolve({ status: 'not-requested', share: null, layers: [] }); if (savedState) { this._hasShareState = true; + applySharedWorldTime(savedState); // Reserve camera authority now; the delayed mesh-friendly flight may // run only if no newer user, voice, or tracking navigation has won. this._initialShareNavigationGeneration = @@ -301,6 +312,7 @@ export class ShareRestoration { layers: [], }); this._initialShareState = state; + applySharedWorldTime(state); this._initialShareSelectionSuperseded = false; this._hasShareState = true; this._navigatedShareRestoreActive = true; From 434fc9602d1cbd06238391754687aa35673fba70 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 21:55:33 -0800 Subject: [PATCH 3/8] 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. --- index.html | 2 +- scripts/package-boundaries.json | 5 +- src/i18n/locales/ar.json | 5 +- src/i18n/locales/de.json | 5 +- src/i18n/locales/es.json | 5 +- src/i18n/locales/fr.json | 5 +- src/i18n/locales/it.json | 5 +- src/i18n/locales/ja.json | 5 +- src/i18n/locales/pt.json | 5 +- src/i18n/locales/ru.json | 5 +- src/i18n/locales/uk.json | 5 +- src/i18n/locales/zh-CN.json | 5 +- src/i18n/locales/zh-Hant.json | 5 +- src/i18n/same-as-english.json | 1 + src/ui/panelLayoutController.js | 2 + src/ui/stores/worldTime.js | 29 ++++++++++ src/ui/styles/world-timeline.css | 62 ++++++++++++++++++++++ src/ui/views/SceneChromeView.vue | 2 + src/ui/views/panels/WorldTimeline.test.mjs | 45 ++++++++++++++++ src/ui/views/panels/WorldTimeline.vue | 34 ++++++++++++ style.css | 1 + 21 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 src/ui/stores/worldTime.js create mode 100644 src/ui/styles/world-timeline.css create mode 100644 src/ui/views/panels/WorldTimeline.test.mjs create mode 100644 src/ui/views/panels/WorldTimeline.vue diff --git a/index.html b/index.html index 803bc2417..b563cf718 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 00f369e40..389adde77 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -146,7 +146,10 @@ "src/ui/stores/recording.js", "src/ui/styleParameters.js", "src/ui/i18n.js", - "src/time/worldClock.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"] }, diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 3dd0b6d73..69ae84544 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3218,5 +3218,8 @@ "Load scenes from an exported JSON file": "تحميل المشاهد من ملف JSON مُصدَّر", "Download the timing log of the last play": "تنزيل سجل توقيت آخر تشغيل", "Edit anchors, data packs and shot details": "تعديل نقاط الارتكاز وحزم البيانات وتفاصيل اللقطة", - "Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد" + "Download this scene as JSON or an asset bundle": "تنزيل هذا المشهد بصيغة JSON أو حزمة موارد", + "World time": "وقت العالم", + "Back to live": "العودة إلى البث المباشر", + "Live": "مباشر" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 144617819..da1bebb4a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2938,5 +2938,8 @@ "Left panels": "Linke Panels", "Right panels": "Rechte Panels", "CONFIRMED": "BESTÄTIGT", - "PAD / RTLS": "STARTPLATZ / RTLS" + "PAD / RTLS": "STARTPLATZ / RTLS", + "World time": "Weltzeit", + "Back to live": "Zurück zu live", + "Live": "Live" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e1d21a5c7..f592a72c5 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3008,5 +3008,8 @@ "Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos", "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos", "Left panels": "Paneles izquierdos", - "Right panels": "Paneles derechos" + "Right panels": "Paneles derechos", + "World time": "Hora mundial", + "Back to live": "Volver al directo", + "Live": "En directo" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 4a6987fad..4d7289ff7 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3008,5 +3008,8 @@ "Left panels": "Panneaux de gauche", "Right panels": "Panneaux de droite", "CONFIRMED": "CONFIRMÉE", - "PAD / RTLS": "PAS DE TIR / RTLS" + "PAD / RTLS": "PAS DE TIR / RTLS", + "World time": "Heure du monde", + "Back to live": "Revenir au direct", + "Live": "Direct" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 1cf792029..065137689 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3008,5 +3008,8 @@ "Left panels": "Pannelli a sinistra", "Right panels": "Pannelli a destra", "CONFIRMED": "CONFERMATA", - "PAD / RTLS": "SITO DI LANCIO / RTLS" + "PAD / RTLS": "SITO DI LANCIO / RTLS", + "World time": "Ora del mondo", + "Back to live": "Torna alla diretta", + "Live": "Diretta" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index df96321e3..3ff743076 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2868,5 +2868,8 @@ "Left panels": "左パネル", "Right panels": "右パネル", "CONFIRMED": "確認済み", - "PAD / RTLS": "射場 / RTLS" + "PAD / RTLS": "射場 / RTLS", + "World time": "世界時刻", + "Back to live": "ライブに戻る", + "Live": "ライブ" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index b4326fc74..455515ab2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3008,5 +3008,8 @@ "Left panels": "Painéis à esquerda", "Right panels": "Painéis à direita", "CONFIRMED": "CONFIRMADA", - "PAD / RTLS": "PLATAFORMA / RTLS" + "PAD / RTLS": "PLATAFORMA / RTLS", + "World time": "Hora do mundo", + "Back to live": "Voltar ao vivo", + "Live": "Ao vivo" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 7bd1ea870..90099d66c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3078,5 +3078,8 @@ "Edit anchors, data packs and shot details": "Изменить якоря, пакеты данных и параметры кадров", "Download this scene as JSON or an asset bundle": "Скачать эту сцену в формате JSON или архивом ресурсов", "Left panels": "Левые панели", - "Right panels": "Правые панели" + "Right panels": "Правые панели", + "World time": "Мировое время", + "Back to live": "Вернуться к прямому эфиру", + "Live": "Сейчас" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index fbe8a6d3f..6ca398f07 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3078,5 +3078,8 @@ "Left panels": "Ліві панелі", "Right panels": "Праві панелі", "CONFIRMED": "ПІДТВЕРДЖЕНО", - "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS" + "PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS", + "World time": "Світовий час", + "Back to live": "Повернутися до прямого ефіру", + "Live": "Зараз" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index ffdd8a4b4..019e97701 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2868,5 +2868,8 @@ "Edit anchors, data packs and shot details": "编辑锚点、数据包和镜头详情", "Download this scene as JSON or an asset bundle": "将此场景下载为 JSON 或资源包", "Left panels": "左侧面板", - "Right panels": "右侧面板" + "Right panels": "右侧面板", + "World time": "世界时间", + "Back to live": "返回实时", + "Live": "实时" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c7b0bb5eb..3e41803e6 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2868,5 +2868,8 @@ "Edit anchors, data packs and shot details": "編輯錨點、數據包和鏡頭詳情", "Download this scene as JSON or an asset bundle": "將此場景下載為 JSON 或資源包", "Left panels": "左側面板", - "Right panels": "右側面板" + "Right panels": "右側面板", + "World time": "世界時間", + "Back to live": "返回即時", + "Live": "即時" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 1cbf64140..a4edfe8d6 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -88,6 +88,7 @@ "Landsat 8/9", "Latin", "Layout", + "Live", "London", "Los Angeles", "MIL", diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index dddedf575..ee0d5541b 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [ '#gev-voice-control', '#pp-toggles', '#param-slider-panel', + '#world-timeline', ].join(', '); /** * Fixed UI regions that can occupy the right control lane. Runtime rectangle @@ -59,6 +60,7 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [ '#cesium-credits .cesium-credit-textContainer', '#command-dock', '#gev-voice-control', + '#world-timeline', ].join(', '); export class PanelLayoutController { constructor({ diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js new file mode 100644 index 000000000..8169982f3 --- /dev/null +++ b/src/ui/stores/worldTime.js @@ -0,0 +1,29 @@ +import { defineStore } from 'pinia'; +import { onScopeDispose, shallowRef } from 'vue'; +import { worldClock } from '../../time/worldClock.js'; + +/** + * The world clock as the timeline shows it: `{live, playing, timeMs}`. 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. + */ +export const useWorldTimeStore = defineStore('gev-world-time', () => { + const read = (state = worldClock.getState()) => + Object.freeze({ + live: state.live, + playing: state.playing, + timeMs: state.timeMs, + }); + const time = shallowRef(read()); + onScopeDispose( + worldClock.subscribe((state) => { + time.value = read(state); + }), + ); + + return { + time, + togglePlay: () => worldClock.togglePlay(), + goLive: () => worldClock.goLive(), + }; +}); diff --git a/src/ui/styles/world-timeline.css b/src/ui/styles/world-timeline.css new file mode 100644 index 000000000..b791b9520 --- /dev/null +++ b/src/ui/styles/world-timeline.css @@ -0,0 +1,62 @@ +/* ── World timeline ─────────────────────────── */ +/* Shown while the world time is away from live, centred above the dock. */ +#world-timeline { + position: fixed; + bottom: calc(2vh + 7rem); + left: 50%; + z-index: 146; + display: flex; + align-items: center; + gap: 10px; + max-width: calc(100vw - 32px); + 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%); +} + +body.ui-clean-view #world-timeline, +body.recording-mode #world-timeline { + bottom: 2vh; +} + +#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 { + 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; +} diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index f77dad3d2..32900f42a 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue'; import AwarenessDirection from './panels/AwarenessDirection.vue'; import UiButton from '../kit/UiButton.vue'; import GlobeActions from './panels/GlobeActions.vue'; +import WorldTimeline from './panels/WorldTimeline.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. const { style } = storeToRefs(useDisplayStore()); @@ -117,4 +118,5 @@ const vToastText = { + diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs new file mode 100644 index 000000000..0fc35c5c2 --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -0,0 +1,45 @@ +// The world timeline shows the world time while it is away from live, 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'; + +test('the timeline shows away from live and plays, pauses and goes back', async (t) => { + document.body.replaceChildren(); + const app = createApp(WorldTimeline).use(createPinia()); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => { + app.unmount(); + worldClock.goLive(); + }); + const bar = () => document.getElementById('world-timeline'); + await nextTick(); + assert.equal(bar(), null, 'hidden while live'); + + const at = Date.UTC(2026, 9, 30, 6, 15, 42); + 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); + + bar().querySelector('.world-timeline-live').click(); + await nextTick(); + assert.equal(worldClock.isLive(), true); + assert.equal(bar(), null); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue new file mode 100644 index 000000000..33026f2a1 --- /dev/null +++ b/src/ui/views/panels/WorldTimeline.vue @@ -0,0 +1,34 @@ + + + diff --git a/style.css b/style.css index 7229ab02f..1def60bb3 100644 --- a/style.css +++ b/style.css @@ -29,6 +29,7 @@ @import './src/ui/styles/provider-settings.css'; @import './src/ui/styles/locale-picker.css'; @import './src/ui/styles/weather.css'; +@import './src/ui/styles/world-timeline.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; @import './src/ui/styles/kit.css'; From 690a6637e26513cb95fd003e22048a1dc8b4836f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 22:36:45 -0800 Subject: [PATCH 4/8] 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. --- src/layers/satellites/catalog.js | 2 + src/layers/satellites/ingestion.js | 1 + src/layers/satellites/ingestion.test.mjs | 1 + src/layers/satellites/lifecycle.js | 9 ++++- src/layers/satellites/state.js | 6 ++- src/layers/weather/clock.js | 42 +++++++++++++++------ src/layers/weather/clock.test.mjs | 44 +++++++++++++++++++++- src/time/worldClock.js | 37 ++++++++++++++++-- src/time/worldClock.test.mjs | 35 +++++++++++++++++ src/ui/stores/worldTime.js | 5 ++- src/ui/views/panels/WorldTimeline.test.mjs | 42 +++++++++++++++++++-- src/ui/views/panels/WorldTimeline.vue | 6 ++- 12 files changed, 205 insertions(+), 25 deletions(-) diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index c5c1aaa75..32e420a04 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -164,6 +164,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._denseStatus = 'ready'; console.log( `[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`, @@ -231,6 +232,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { layerState._denseCursor = 0; layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); } return { _abortActiveUpdates, diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 3db7d7471..b02f8371a 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -170,6 +170,7 @@ export function createIngestion({ layerState._coreStale = coreStale; layerState._count = layerState._points.size; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._lastUpdate = Date.now(); layerState._lastPropagation = Date.now(); layerState._lastTrackingRefreshOutcome = { diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index d08f8b7a8..663541c9c 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -33,6 +33,7 @@ function ingestionFor(catalogText) { _catalogRevision: 0, _params: { catalog: 'core', showOrbits: false }, now: Date.now, + coverageChanged() {}, }; const parts = { orbits: { diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js index 896f2fe96..b12600c10 100644 --- a/src/layers/satellites/lifecycle.js +++ b/src/layers/satellites/lifecycle.js @@ -13,7 +13,8 @@ export function createLifecycle({ // their element sets. let releaseCoverage = null; const coverWorldTime = () => { - releaseCoverage ??= worldClock.register({ + if (releaseCoverage) return; + const release = worldClock.register({ id: 'satellites', coverage: () => { if (!layerState._catalog.size) return null; @@ -24,6 +25,10 @@ export function createLifecycle({ }; }, }); + // Registering tells the world's listeners, which may turn the layer off + // (or off and on, registering again) before this returns. + if (layerState._enabled && !releaseCoverage) releaseCoverage = release; + else release(); }; const uncoverWorldTime = () => { releaseCoverage?.(); @@ -67,6 +72,7 @@ export function createLifecycle({ layerState._denseStatus = 'idle'; layerState._denseError = null; layerState._catalogRevision++; + layerState.coverageChanged(); // Point primitives for satellite dots layerState._pointCollection = new Cesium.PointPrimitiveCollection(); @@ -185,6 +191,7 @@ export function createLifecycle({ layerState._denseStatus = 'idle'; layerState._denseError = null; layerState._catalogRevision++; + layerState.coverageChanged(); layerState._rowControlsListener = null; layerState._count = 0; layerState._lastUpdate = null; diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js index 44e004476..8464fb275 100644 --- a/src/layers/satellites/state.js +++ b/src/layers/satellites/state.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { worldTimeFor } from '../../time/worldClock.js'; +import { worldClock, worldTimeFor } from '../../time/worldClock.js'; export function createState({ services }) { const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = @@ -156,6 +156,10 @@ export function createState({ services }) { state.now = worldTimeFor('satellites'); + /** The catalog changed, so the time satellites can be shown at may have. */ + + state.coverageChanged = () => worldClock.coverageChanged(); + // Scratch variables state._scratchCartesian = new Cesium.Cartesian3(); diff --git a/src/layers/weather/clock.js b/src/layers/weather/clock.js index 9e2d5a96f..67973bf94 100644 --- a/src/layers/weather/clock.js +++ b/src/layers/weather/clock.js @@ -162,7 +162,34 @@ export function createWeatherClock({ throw new Error(`Duplicate weather product: ${product.id}`); const entry = { product, controller: null }; registry.set(product.id, entry); - releases.set(product.id, world.hold(product.id)); + // The product moves with the world clock, and its frames are time the + // world clock's timeline can show while it is on, suspended or not, so + // the way back to live stays on screen. Both calls notify the world's + // listeners, which may destroy this clock: the release is in place + // first, and anything acquired after a destroy is let go at once. + let unhold = null; + let uncover = null; + const release = () => { + uncover?.(); + unhold?.(); + uncover = unhold = null; + }; + releases.set(product.id, release); + unhold = world.hold(product.id); + if (!destroyed) + uncover = world.register({ + id: product.id, + coverage: () => { + const marks = timesFor(product).map((time) => Date.parse(time)); + return marks.length + ? { start: marks[0], end: marks.at(-1), marks } + : null; + }, + }); + if (destroyed) { + release(); + return () => {}; + } void api.refresh(); return () => { if (registry.get(product.id) !== entry) return; @@ -242,6 +269,8 @@ export function createWeatherClock({ }, refresh() { if (destroyed) return Promise.resolve(false); + // New frames, or products added or dropped. + world.coverageChanged(); if (!canPlay()) { stopPlaying(); cancelTimer(); @@ -262,7 +291,6 @@ export function createWeatherClock({ ++generation; cancelTimer(); unfollowWorld(); - uncover(); // 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(); @@ -292,16 +320,6 @@ export function createWeatherClock({ else notify(); } const unfollowWorld = world.subscribe(followWorld); - // The observed frames this clock can show, for a host's timeline. - const uncover = world.register({ - id: 'weather', - coverage: () => { - const marks = getTimeline().map((time) => Date.parse(time)); - return marks.length - ? { start: marks[0], end: marks.at(-1), marks } - : null; - }, - }); driver = world.attachDriver({ play: () => (canPlay() ? void api.play() : false), pause: () => api.pause(), diff --git a/src/layers/weather/clock.test.mjs b/src/layers/weather/clock.test.mjs index 9561c10c7..c803e5d72 100644 --- a/src/layers/weather/clock.test.mjs +++ b/src/layers/weather/clock.test.mjs @@ -436,9 +436,10 @@ test('a target superseded from a listener resolves false', async (t) => { assert.equal(radar.shown, time(10)); }); -test('the observed frames are the world clock coverage, while registered', (t) => { +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])); @@ -447,7 +448,48 @@ test('the observed frames are the world clock coverage, while registered', (t) = end: Date.parse(time(10)), marks: [0, 5, 10].map((m) => Date.parse(time(m))), }); + assert.equal(world.getState().supported, true); unregister(); + assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]); clock.destroy(); assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); +}); + +test('a suspended product keeps its coverage, so the way back to live stays', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', [0, 10]); + clock.register(radar); + radar.suspended = true; + void clock.refresh(); + assert.equal(world.getState().supported, true); + assert.deepEqual( + world.coverage().marks, + [0, 10].map((m) => Date.parse(time(m))), + ); +}); + +test('new frames reach the world clock through refresh', (t) => { + const { clock, world } = harness(t); + const radar = product('radar', []); + clock.register(radar); + const supported = []; + world.subscribe((state) => supported.push(state.supported)); + assert.equal(world.getState().supported, false); + radar.times = [time(0)]; + void clock.refresh(); + assert.equal(supported.at(-1), true); +}); + +test('a clock destroyed while it registers a product lets go of it', (t) => { + const { clock, world } = harness(t); + const off = world.subscribe(() => { + off(); + clock.destroy(); + }); + const unregister = clock.register(product('radar', [0, 10])); + unregister(); + assert.equal(world.follows('radar'), false); + assert.equal(world.coverage(), null); + assert.equal(world.getState().supported, false); }); diff --git a/src/time/worldClock.js b/src/time/worldClock.js index 446318a43..b50d8f5a4 100644 --- a/src/time/worldClock.js +++ b/src/time/worldClock.js @@ -61,6 +61,25 @@ export function createWorldClock({ 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; @@ -73,17 +92,20 @@ export function createWorldClock({ 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(getState()); + listener((state ??= getState())); } catch (error) { console.error('World clock listener failed', error); } @@ -270,16 +292,23 @@ export function createWorldClock({ // Each registration is its own, even under a shared id. const entry = { source }; sources.add(entry); - return () => sources.delete(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 = source.coverage(); - if (!span || !(span.end >= span.start)) continue; + 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); diff --git a/src/time/worldClock.test.mjs b/src/time/worldClock.test.mjs index a954f60fd..99d81084f 100644 --- a/src/time/worldClock.test.mjs +++ b/src/time/worldClock.test.mjs @@ -252,3 +252,38 @@ test('seek refuses a time no Date can hold', () => { clock.seek(-8.64e15); assert.equal(clock.now(), -8.64e15); }); + +test('only a followed layer with time to show supports the timeline', () => { + const clock = createWorldClock(fakeTime().options); + let span = null; + const states = []; + clock.subscribe((state) => states.push(state.supported)); + const off = clock.register({ id: 'satellites', coverage: () => span }); + span = { start: 0, end: 10 }; + clock.coverageChanged(); + assert.equal(clock.isSupported(), false, 'covered but not followed'); + clock.follow('satellites'); + assert.equal(clock.isSupported(), true); + assert.equal(states.at(-1), true); + span = null; + clock.coverageChanged(); + assert.equal(states.at(-1), false); + span = { start: 0, end: 10 }; + off(); + assert.equal(clock.isSupported(), false); + assert.equal(states.at(-1), false); +}); + +test('a coverage that throws counts as none', (t) => { + t.mock.method(console, 'error', () => {}); + const clock = createWorldClock(fakeTime().options); + clock.follow('broken'); + clock.register({ + id: 'broken', + coverage() { + throw new Error('no'); + }, + }); + assert.equal(clock.getState().supported, false); + assert.equal(clock.coverage(), null); +}); diff --git a/src/ui/stores/worldTime.js b/src/ui/stores/worldTime.js index 8169982f3..1c39fbe66 100644 --- a/src/ui/stores/worldTime.js +++ b/src/ui/stores/worldTime.js @@ -3,7 +3,9 @@ import { onScopeDispose, shallowRef } from 'vue'; import { worldClock } from '../../time/worldClock.js'; /** - * The world clock as the timeline shows it: `{live, playing, timeMs}`. It + * The world clock as the timeline shows it: `{live, playing, timeMs, + * supported}`, where `supported` says a layer that moves with the clock has + * time to show. 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. */ @@ -13,6 +15,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => { live: state.live, playing: state.playing, timeMs: state.timeMs, + supported: state.supported, }); const time = shallowRef(read()); onScopeDispose( diff --git a/src/ui/views/panels/WorldTimeline.test.mjs b/src/ui/views/panels/WorldTimeline.test.mjs index 0fc35c5c2..2a10d06b3 100644 --- a/src/ui/views/panels/WorldTimeline.test.mjs +++ b/src/ui/views/panels/WorldTimeline.test.mjs @@ -8,7 +8,7 @@ import { createPinia } from 'pinia'; import { worldClock } from '../../../time/worldClock.js'; import WorldTimeline from './WorldTimeline.vue'; -test('the timeline shows away from live and plays, pauses and goes back', async (t) => { +function mount(t) { document.body.replaceChildren(); const app = createApp(WorldTimeline).use(createPinia()); app.mount(document.body.appendChild(document.createElement('div'))); @@ -16,11 +16,33 @@ test('the timeline shows away from live and plays, pauses and goes back', async app.unmount(); worldClock.goLive(); }); - const bar = () => document.getElementById('world-timeline'); + 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 away from 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.equal(bar(), null, 'hidden while live'); - const at = Date.UTC(2026, 9, 30, 6, 15, 42); worldClock.seek(at); await nextTick(); assert.ok(bar()); @@ -43,3 +65,17 @@ test('the timeline shows away from live and plays, pauses and goes back', async assert.equal(worldClock.isLive(), true); assert.equal(bar(), null); }); + +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); + 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'); +}); diff --git a/src/ui/views/panels/WorldTimeline.vue b/src/ui/views/panels/WorldTimeline.vue index 33026f2a1..ea1da1fc3 100644 --- a/src/ui/views/panels/WorldTimeline.vue +++ b/src/ui/views/panels/WorldTimeline.vue @@ -8,7 +8,9 @@ import UiIcon from '../../kit/UiIcon.vue'; // The world time while it is away from live: what is playing, or where it // was paused, with play and pause on the left and the way back to live on -// the right. Live, the bar stays hidden. +// the right. It shows only while a layer that moves with the clock (weather +// radar, satellite or lightning, or one a host follows) is on with time to +// show; live, or with none on, the bar stays hidden. const store = useWorldTimeStore(); const { time } = storeToRefs(store); @@ -25,7 +27,7 @@ const readout = computed(() =>