mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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.
This commit is contained in:
1 parent
92bb1d6a89
commit
0c4da3cdb2
43 files changed
+1091
-75
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
+14
-1
@@ -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()) {
|
||||
|
||||
+9
-4
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -175,7 +175,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+4
-1
@@ -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 {
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -32,6 +32,7 @@ function ingestionFor(catalogText) {
|
||||
_denseLoadToken: 0,
|
||||
_catalogRevision: 0,
|
||||
_params: { catalog: 'core', showOrbits: false },
|
||||
now: Date.now,
|
||||
};
|
||||
const parts = {
|
||||
orbits: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSelection } from './selection.js';
|
||||
import { createViewerHost } from './viewerHost.js';
|
||||
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
|
||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
import { decodeParams, encodeParams } from './params.js';
|
||||
import {
|
||||
composeUIState,
|
||||
@@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000;
|
||||
* a time.
|
||||
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
||||
*/
|
||||
// "Since N days" counts back from the world time.
|
||||
const streetLevelTime = worldTimeFor(STREET_LEVEL_LAYER_ID);
|
||||
|
||||
export function createStreetLevelLayer({
|
||||
providers: definitions,
|
||||
services = {},
|
||||
@@ -93,7 +97,7 @@ export function createStreetLevelLayer({
|
||||
return Object.freeze({
|
||||
services: state.services,
|
||||
/** Resolved against now, so "since N days" keeps up with the clock. */
|
||||
getFilter: () => resolveFilter(state.filter),
|
||||
getFilter: () => resolveFilter(state.filter, streetLevelTime()),
|
||||
/** The layer and this provider's switch are both on. */
|
||||
isActive: () => state.enabled && entry.on,
|
||||
notify,
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
+110
-17
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user