mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab54675ba | ||
|
|
09d833ed96 | ||
|
|
32e08f5043 | ||
|
|
48093108b1 | ||
|
|
9faec3028e | ||
|
|
3534398771 | ||
|
|
690a6637e2 | ||
|
|
434fc9602d | ||
|
|
32ec6ed436 | ||
|
|
0c4da3cdb2 |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
- One world clock now drives everything with a time of its own: the sky,
|
||||||
|
the HUD clock, satellites, launches and weather. A timeline along the
|
||||||
|
bottom of the screen, shown while one of those layers is on, plays, pauses
|
||||||
|
and scrubs it, with a tick for each launch or frame and a way back to
|
||||||
|
live. Share links made away from live carry the time (samehkhamis, #1512).
|
||||||
@@ -119,6 +119,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
|
|||||||
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
|
||||||
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
|
||||||
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||||
|
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
|
||||||
|
|
||||||
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
|
||||||
so v1 never writes it; a later view kind writes its code.
|
so v1 never writes it; a later view kind writes its code.
|
||||||
@@ -132,6 +133,10 @@ style also extends the pinned style-defaults table.
|
|||||||
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
`hudVariant` code 1 (`operator`) is retired: the app no longer offers that
|
||||||
layout and restores it as `tactical`. The code stays reserved.
|
layout and restores it as `tactical`. The code stays reserved.
|
||||||
|
|
||||||
|
Field 28 is the world time the link was made at, written only when it was away
|
||||||
|
from live. A link without it opens live. Unlike field 8 it may lie ahead of the
|
||||||
|
link's creation (a forecast moment), and it opens paused at that time.
|
||||||
|
|
||||||
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||||
names a glyph this list is missing, because a missing glyph does not draw
|
names a glyph this list is missing, because a missing glyph does not draw
|
||||||
a box — the ligature never forms and the element renders the literal word. -->
|
a box — the ligature never forms and the element renders the literal word. -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,play_arrow,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
"./infrastructure/lod": "./src/data/localGeojsonLod.js",
|
||||||
"./application": "./src/app/application.js",
|
"./application": "./src/app/application.js",
|
||||||
"./application/viewer": "./src/app/viewer.js",
|
"./application/viewer": "./src/app/viewer.js",
|
||||||
|
"./time": "./src/time/worldClock.js",
|
||||||
"./build/vite": {
|
"./build/vite": {
|
||||||
"node": "./build/vite.js"
|
"node": "./build/vite.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -149,10 +149,19 @@
|
|||||||
"src/ui/views/panels/GlobeCompass.vue",
|
"src/ui/views/panels/GlobeCompass.vue",
|
||||||
"src/ui/stores/recording.js",
|
"src/ui/stores/recording.js",
|
||||||
"src/ui/styleParameters.js",
|
"src/ui/styleParameters.js",
|
||||||
"src/ui/i18n.js"
|
"src/ui/i18n.js",
|
||||||
|
"src/time/worldClock.js",
|
||||||
|
"src/ui/kit/UiIcon.vue",
|
||||||
|
"src/ui/stores/worldTime.js",
|
||||||
|
"src/ui/views/panels/WorldTimeline.vue"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia", "vue-i18n"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
|
"time": {
|
||||||
|
"exports": ["./time"],
|
||||||
|
"modules": ["src/time/worldClock.js"],
|
||||||
|
"external": []
|
||||||
|
},
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"exports": ["./application/viewer"],
|
"exports": ["./application/viewer"],
|
||||||
"modules": [
|
"modules": [
|
||||||
@@ -1123,7 +1132,8 @@
|
|||||||
"src/ui/kit/UiBadge.vue",
|
"src/ui/kit/UiBadge.vue",
|
||||||
"src/ui/stores/globeActions.js",
|
"src/ui/stores/globeActions.js",
|
||||||
"src/ui/stores/recording.js",
|
"src/ui/stores/recording.js",
|
||||||
"src/ui/i18n.js"
|
"src/ui/i18n.js",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
"external": ["cesium", "vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
@@ -1334,7 +1344,9 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/unitLabels.js"
|
"src/ui/unitLabels.js",
|
||||||
|
"src/time/worldClock.js",
|
||||||
|
"src/time/julian.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1390,7 +1402,9 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/time/worldClock.js",
|
||||||
|
"src/time/julian.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1575,7 +1589,8 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/unitLabels.js"
|
"src/ui/unitLabels.js",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "satellite.js"]
|
"external": ["cesium", "satellite.js"]
|
||||||
},
|
},
|
||||||
@@ -1615,7 +1630,8 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -1935,7 +1951,8 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js"
|
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||||
},
|
},
|
||||||
@@ -3175,6 +3192,9 @@
|
|||||||
"src/ui/stores/globeActions.js",
|
"src/ui/stores/globeActions.js",
|
||||||
"src/ui/stores/recording.js",
|
"src/ui/stores/recording.js",
|
||||||
"src/ui/i18n.js",
|
"src/ui/i18n.js",
|
||||||
|
"src/time/viewerClock.js",
|
||||||
|
"src/time/worldClock.js",
|
||||||
|
"src/time/julian.js",
|
||||||
"src/annotations/outlineRungs.js",
|
"src/annotations/outlineRungs.js",
|
||||||
"src/sources/openFreeMapOutlines.js",
|
"src/sources/openFreeMapOutlines.js",
|
||||||
"src/sources/nominatimOutlines.js",
|
"src/sources/nominatimOutlines.js",
|
||||||
@@ -3782,6 +3802,8 @@
|
|||||||
"src/ui/stores/bhoteKoshiPanel.js",
|
"src/ui/stores/bhoteKoshiPanel.js",
|
||||||
"src/hudReadouts.js",
|
"src/hudReadouts.js",
|
||||||
"src/ui/i18n.js",
|
"src/ui/i18n.js",
|
||||||
|
"src/time/worldClock.js",
|
||||||
|
"src/time/julian.js",
|
||||||
"src/annotations/outlineRungs.js",
|
"src/annotations/outlineRungs.js",
|
||||||
"src/sources/openFreeMapOutlines.js",
|
"src/sources/openFreeMapOutlines.js",
|
||||||
"src/sources/nominatimOutlines.js",
|
"src/sources/nominatimOutlines.js",
|
||||||
@@ -3941,7 +3963,8 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -4460,7 +4483,8 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "egm96-universal", "pbf"]
|
"external": ["cesium", "egm96-universal", "pbf"]
|
||||||
},
|
},
|
||||||
@@ -4512,7 +4536,8 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json"
|
"src/i18n/locales/zh-Hant.json",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "pbf"]
|
"external": ["cesium", "pbf"]
|
||||||
},
|
},
|
||||||
@@ -4632,7 +4657,8 @@
|
|||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/scopeMaskPolicy.js",
|
"src/scopeMaskPolicy.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/visualStylePolicy.js"
|
"src/visualStylePolicy.js",
|
||||||
|
"src/time/worldClock.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { resolveCatalogSources } from './sourceComposition.js';
|
import { resolveCatalogSources } from './sourceComposition.js';
|
||||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||||
|
import { worldClock } from '../time/worldClock.js';
|
||||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||||
import { createWindLayer } from '../layers/wind/index.js';
|
import { createWindLayer } from '../layers/wind/index.js';
|
||||||
@@ -72,7 +73,7 @@ export function createApplicationCatalog({
|
|||||||
const sourceComposition = resolveCatalogSources(sources);
|
const sourceComposition = resolveCatalogSources(sources);
|
||||||
sources = sourceComposition.sources;
|
sources = sourceComposition.sources;
|
||||||
const militaryRegistry = createMilitaryRegistry();
|
const militaryRegistry = createMilitaryRegistry();
|
||||||
const weatherClock = createWeatherClock();
|
const weatherClock = createWeatherClock({ world: worldClock });
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
signal.removeEventListener('abort', dispose);
|
signal.removeEventListener('abort', dispose);
|
||||||
militaryRegistry.dispose();
|
militaryRegistry.dispose();
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
||||||
|
import { worldTimeFor } from '../../time/worldClock.js';
|
||||||
|
|
||||||
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
|
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
|
||||||
export function createApplicationGnssIntegrity(options) {
|
export function createApplicationGnssIntegrity(options) {
|
||||||
return createGnssIntegrityLayer(options);
|
return createGnssIntegrityLayer({
|
||||||
|
worldNow: worldTimeFor('gnss-integrity'),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,9 @@ import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
|
|||||||
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
||||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||||
import { governorRequestRender } from '../../renderGovernor.js';
|
import { governorRequestRender } from '../../renderGovernor.js';
|
||||||
|
import { worldTimeFor } from '../../time/worldClock.js';
|
||||||
|
|
||||||
|
const imageryTime = worldTimeFor('recent-imagery');
|
||||||
|
|
||||||
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||||
export function createApplicationRecentImagery({ source }) {
|
export function createApplicationRecentImagery({ source }) {
|
||||||
@@ -15,5 +18,6 @@ export function createApplicationRecentImagery({ source }) {
|
|||||||
thumbnails: createThumbnailLoader({
|
thumbnails: createThumbnailLoader({
|
||||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||||
}),
|
}),
|
||||||
|
now: () => new Date(imageryTime()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
governorRequestRender,
|
governorRequestRender,
|
||||||
} from '../renderGovernor.js';
|
} from '../renderGovernor.js';
|
||||||
import { describeError } from './errors.js';
|
import { describeError } from './errors.js';
|
||||||
|
import { worldClock, worldTimeFor } from '../time/worldClock.js';
|
||||||
|
import { followWorldTime } from '../time/viewerClock.js';
|
||||||
import { onLocaleChange, t } from '../i18n/index.js';
|
import { onLocaleChange, t } from '../i18n/index.js';
|
||||||
|
|
||||||
/** Construct the application globe using the caller's local configuration. */
|
/** Construct the application globe using the caller's local configuration. */
|
||||||
@@ -58,6 +60,12 @@ export async function createApplicationScene({
|
|||||||
if (!viewer.isDestroyed()) viewer.destroy();
|
if (!viewer.isDestroyed()) viewer.destroy();
|
||||||
});
|
});
|
||||||
defer(installTrackpadPinchZoom(viewer));
|
defer(installTrackpadPinchZoom(viewer));
|
||||||
|
defer(
|
||||||
|
followWorldTime(viewer, worldTimeFor('sky'), {
|
||||||
|
subscribe: worldClock.subscribe,
|
||||||
|
requestRender: () => governorRequestRender('world-time'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
registerDataCredits(viewer, credits);
|
registerDataCredits(viewer, credits);
|
||||||
configureCreditKeyboardAccess(document);
|
configureCreditKeyboardAccess(document);
|
||||||
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,9 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { governorRequestRender } from './renderGovernor.js';
|
import { governorRequestRender } from './renderGovernor.js';
|
||||||
|
import { worldClock, worldTimeFor } from './time/worldClock.js';
|
||||||
|
|
||||||
|
// The ring shows the sun and moon at the time the sky is drawn.
|
||||||
|
const skyTime = worldTimeFor('sky');
|
||||||
|
|
||||||
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
||||||
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
||||||
@@ -445,6 +449,12 @@ export class CelestialRing {
|
|||||||
if (typeof document !== 'undefined' && document.hidden) return;
|
if (typeof document !== 'undefined' && document.hidden) return;
|
||||||
governorRequestRender('celestial-ephemeris');
|
governorRequestRender('celestial-ephemeris');
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
// A jump in the world time moves the sun and moon at once.
|
||||||
|
this._skyDate = new Date();
|
||||||
|
this._skyTime = new Cesium.JulianDate();
|
||||||
|
this._unsubscribeWorldTime = worldClock.subscribe(() => {
|
||||||
|
this._ephemerisDirty = true;
|
||||||
|
});
|
||||||
this.setEnabled(this.enabled);
|
this.setEnabled(this.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,7 +811,8 @@ export class CelestialRing {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const time = Cesium.JulianDate.now();
|
this._skyDate.setTime(skyTime());
|
||||||
|
const time = Cesium.JulianDate.fromDate(this._skyDate, this._skyTime);
|
||||||
if (!this._updateEphemeris(time)) return;
|
if (!this._updateEphemeris(time)) return;
|
||||||
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
|
this._present({ ephemerisUpdates: String(this._ephemerisUpdateCount) });
|
||||||
const camera = this.viewer.camera;
|
const camera = this.viewer.camera;
|
||||||
@@ -890,6 +901,8 @@ export class CelestialRing {
|
|||||||
clearInterval(this._ephemerisTimer);
|
clearInterval(this._ephemerisTimer);
|
||||||
this._ephemerisTimer = null;
|
this._ephemerisTimer = null;
|
||||||
}
|
}
|
||||||
|
this._unsubscribeWorldTime?.();
|
||||||
|
this._unsubscribeWorldTime = null;
|
||||||
if (this._removePostRender) this._removePostRender();
|
if (this._removePostRender) this._removePostRender();
|
||||||
this._removePostRender = null;
|
this._removePostRender = null;
|
||||||
if (this._shown()) {
|
if (this._shown()) {
|
||||||
|
|||||||
@@ -229,13 +229,30 @@ const GUARDED_PROPS = new Set([
|
|||||||
|
|
||||||
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
|
// Custom properties allowed inside a modelled offset. Each is a pinned-tray
|
||||||
// stack height, proven non-negative below, so evaluating them at 0 is a
|
// stack height, proven non-negative below, so evaluating them at 0 is a
|
||||||
// conservative floor rather than a guess.
|
// conservative floor rather than a guess. The world timeline's lift raises
|
||||||
|
// the dock, its trays and the credit together, so it moves no gap either.
|
||||||
const VETTED_VARS = new Set([
|
const VETTED_VARS = new Set([
|
||||||
'--dock-pinned-stack-height',
|
'--dock-pinned-stack-height',
|
||||||
'--dock-lower-pinned-height',
|
'--dock-lower-pinned-height',
|
||||||
'--dock-location-pinned-height',
|
'--dock-location-pinned-height',
|
||||||
'--dock-presets-pinned-height',
|
'--dock-presets-pinned-height',
|
||||||
|
'--world-timeline-lift',
|
||||||
]);
|
]);
|
||||||
|
// Custom properties CSS sets to a fixed length besides their 0 default. The
|
||||||
|
// clearance checks run at each length CSS gives them, not only at 0.
|
||||||
|
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
|
||||||
|
const LIFTS_PX = [0, 50, 80];
|
||||||
|
let liftPx = 0;
|
||||||
|
function eachLift(check) {
|
||||||
|
for (const lift of LIFTS_PX) {
|
||||||
|
liftPx = lift;
|
||||||
|
try {
|
||||||
|
check(lift);
|
||||||
|
} finally {
|
||||||
|
liftPx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
|
/** Rules whose final compound targets a modelled element (pseudo-elements aside). */
|
||||||
function ownBoxEntries() {
|
function ownBoxEntries() {
|
||||||
@@ -315,6 +332,7 @@ function toPx(value, viewportHeight, where) {
|
|||||||
if (term.startsWith('var(')) {
|
if (term.startsWith('var(')) {
|
||||||
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
|
const name = term.slice(4, term.lastIndexOf(')')).split(',')[0].trim();
|
||||||
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
|
assert.ok(VETTED_VARS.has(name), `unvetted custom property ${name} in ${where}: "${value}"`);
|
||||||
|
if (name === '--world-timeline-lift') total += liftPx;
|
||||||
continue; // proven non-negative; 0 is the conservative floor
|
continue; // proven non-negative; 0 is the conservative floor
|
||||||
}
|
}
|
||||||
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
|
assert.match(term, /^-?[\d.]+(px|rem|vh)$/, `unmodelled length term "${term}" in ${where}: "${value}"`);
|
||||||
@@ -444,7 +462,8 @@ test('custom properties inside modelled offsets are provably non-negative', () =
|
|||||||
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
|
assert.ok(declared.length >= VETTED_VARS.size, 'every vetted custom property needs a CSS default');
|
||||||
for (const decl of declared) {
|
for (const decl of declared) {
|
||||||
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
|
assert.doesNotMatch(decl.value, /-\s*\d/, `${decl.prop} has a negative CSS default: ${decl.value}`);
|
||||||
assert.match(decl.value, /^(0|0px)$/, `${decl.prop} default is not a vetted shape: ${decl.value}`);
|
const shape = FIXED_LENGTH_VARS.has(decl.prop) ? /^(0|\d+px)$/ : /^(0|0px)$/;
|
||||||
|
assert.match(decl.value, shape, `${decl.prop} default is not a vetted shape: ${decl.value}`);
|
||||||
}
|
}
|
||||||
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
|
const layout = fs.readFileSync(new URL('./ui/panelLayoutController.js', import.meta.url), 'utf8');
|
||||||
const start = layout.indexOf('_updateCommandDockTrayStack() {');
|
const start = layout.indexOf('_updateCommandDockTrayStack() {');
|
||||||
@@ -517,16 +536,18 @@ test('every open dock tray clears the required credit at every modelled viewport
|
|||||||
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
|
// number: the dock is anchored at 2vh down to 721px and re-anchors to a flat
|
||||||
// 8px at 720px, while the credit keeps its 2vh base throughout.
|
// 8px at 720px, while the credit keeps its 2vh base throughout.
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
eachLift((lift) => {
|
||||||
for (const scenario of TRAY_SCENARIOS) {
|
for (const scenario of TRAY_SCENARIOS) {
|
||||||
for (const width of WIDTHS.filter((w) => w <= 900)) {
|
for (const width of WIDTHS.filter((w) => w <= 900)) {
|
||||||
for (const height of HEIGHTS) {
|
for (const height of HEIGHTS) {
|
||||||
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
|
const clearance = trayBottomPx(scenario, width, height) - creditTopPx(width, height);
|
||||||
if (clearance < MIN_CLEARANCE_PX) {
|
if (clearance < MIN_CLEARANCE_PX) {
|
||||||
failures.push(`${scenario.name} @ ${width}x${height}: ${clearance.toFixed(1)}px`);
|
failures.push(`${scenario.name} @ ${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
|
assert.deepEqual(failures, [], `a dock tray re-enters the credit band:\n ${failures.join('\n ')}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -566,21 +587,24 @@ test('the full-width context rail clears the required credit at every modelled v
|
|||||||
'none',
|
'none',
|
||||||
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
|
`at ${width}px the rail is height-capped, so its bottom anchor no longer decides its floor`,
|
||||||
);
|
);
|
||||||
|
eachLift((lift) => {
|
||||||
for (const height of HEIGHTS) {
|
for (const height of HEIGHTS) {
|
||||||
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
|
const rail = resolve(['#right-context-rail'], 'bottom', width, 'context rail');
|
||||||
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
|
const clearance = toPx(rail.decl.value, height, 'rail bottom') - creditTopPx(width, height);
|
||||||
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height}: ${clearance.toFixed(1)}px`);
|
if (clearance < MIN_CLEARANCE_PX) failures.push(`${width}x${height} lift ${lift}: ${clearance.toFixed(1)}px`);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
|
assert.deepEqual(failures, [], `context rail re-enters the credit band at ${failures.join(', ')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
|
test('the dock anchor changes at 720px — the 2vh cancellation is band-limited', () => {
|
||||||
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, '2vh');
|
const lift = 'var(--world-timeline-lift, 0px)';
|
||||||
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, '8px');
|
assert.equal(resolve(['#command-dock'], 'bottom', 800, 'dock').decl.value, `calc(2vh + ${lift})`);
|
||||||
|
assert.equal(resolve(['#command-dock'], 'bottom', 720, 'dock').decl.value, `calc(8px + ${lift})`);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
|
resolve(CREDIT_SELECTORS, 'bottom', 720, 'credit').decl.value,
|
||||||
'calc(2vh + 5rem)',
|
`calc(2vh + 5rem + ${lift})`,
|
||||||
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
|
'the credit keeps its 2vh base below 720px — that asymmetry is the whole hazard',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { gstime } from 'satellite.js';
|
import { gstime } from 'satellite.js';
|
||||||
|
import { worldClock } from '../time/worldClock.js';
|
||||||
import { createModel } from '../layers/launches/model.js';
|
import { createModel } from '../layers/launches/model.js';
|
||||||
import rocketLaunchesLayer, {
|
import rocketLaunchesLayer, {
|
||||||
_setRocketMissionOverlayHostForTest,
|
_setRocketMissionOverlayHostForTest,
|
||||||
@@ -1240,7 +1241,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
|||||||
rocketLaunchesLayer.init(viewer);
|
rocketLaunchesLayer.init(viewer);
|
||||||
initialized = true;
|
initialized = true;
|
||||||
await rocketLaunchesLayer.enable();
|
await rocketLaunchesLayer.enable();
|
||||||
|
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
|
||||||
await rocketLaunchesLayer.update();
|
await rocketLaunchesLayer.update();
|
||||||
|
const at = Date.parse(launchTime);
|
||||||
|
assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] });
|
||||||
|
assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline');
|
||||||
|
|
||||||
const entities = dataSources[0].entities.values;
|
const entities = dataSources[0].entities.values;
|
||||||
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path');
|
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path');
|
||||||
@@ -1379,6 +1384,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
|||||||
assert.equal(deselectedPublication[2][0].protected, false);
|
assert.equal(deselectedPublication[2][0].protected, false);
|
||||||
|
|
||||||
await rocketLaunchesLayer.disable();
|
await rocketLaunchesLayer.disable();
|
||||||
|
assert.equal(worldClock.coverage(), null, 'off, launches show no time');
|
||||||
assert.deepEqual(hostCalls.slice(-4), [
|
assert.deepEqual(hostCalls.slice(-4), [
|
||||||
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
|
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
|
||||||
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
|
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
|
||||||
@@ -1500,3 +1506,11 @@ test('missing payload details stay unknown without inventing mass or classificat
|
|||||||
const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
|
const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
|
||||||
assert.deepEqual(launch.payloads, []);
|
assert.deepEqual(launch.payloads, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('away from live the feed keeps its own window of launches', (t) => {
|
||||||
|
t.after(() => worldClock.goLive());
|
||||||
|
worldClock.seek(Date.now() - 20 * 86_400_000);
|
||||||
|
const net = new Date(Date.now() - 86_400_000).toISOString();
|
||||||
|
const launches = normalizeRocketLaunches([{ id: 'recent', pad: { latitude: 1, longitude: 2 }, net }]);
|
||||||
|
assert.deepEqual(launches.map((launch) => launch.id), ['recent']);
|
||||||
|
});
|
||||||
+9
-4
@@ -23,6 +23,11 @@ import * as Cesium from 'cesium';
|
|||||||
import { greatCircleKm } from './geoDistance.js';
|
import { greatCircleKm } from './geoDistance.js';
|
||||||
import { forward as toMGRS } from 'mgrs';
|
import { forward as toMGRS } from 'mgrs';
|
||||||
import { CITY_POIS } from './cityPois.js';
|
import { CITY_POIS } from './cityPois.js';
|
||||||
|
import { worldTimeFor } from './time/worldClock.js';
|
||||||
|
|
||||||
|
// The HUD stamps its readouts with the world time; its sun is the sky's.
|
||||||
|
const hudTime = worldTimeFor('hud');
|
||||||
|
const skyTime = worldTimeFor('sky');
|
||||||
import { composeLocalityTag } from './hudLocality.js';
|
import { composeLocalityTag } from './hudLocality.js';
|
||||||
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
|
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
|
||||||
import {
|
import {
|
||||||
@@ -393,11 +398,11 @@ export class IntelHUD {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format the current wall-clock time as a UTC Zulu string.
|
* Format the HUD's world time as a UTC Zulu string.
|
||||||
* @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format.
|
* @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format.
|
||||||
*/
|
*/
|
||||||
_formatUTC() {
|
_formatUTC() {
|
||||||
const now = new Date();
|
const now = new Date(hudTime());
|
||||||
const y = now.getUTCFullYear();
|
const y = now.getUTCFullYear();
|
||||||
const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
|
const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||||
const d = String(now.getUTCDate()).padStart(2, '0');
|
const d = String(now.getUTCDate()).padStart(2, '0');
|
||||||
@@ -500,7 +505,7 @@ export class IntelHUD {
|
|||||||
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`;
|
||||||
|
|
||||||
// Collection timestamp
|
// Collection timestamp
|
||||||
const now = new Date();
|
const now = new Date(hudTime());
|
||||||
const h = String(now.getUTCHours()).padStart(2, '0');
|
const h = String(now.getUTCHours()).padStart(2, '0');
|
||||||
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
const m = String(now.getUTCMinutes()).padStart(2, '0');
|
||||||
const s = String(now.getUTCSeconds()).padStart(2, '0');
|
const s = String(now.getUTCSeconds()).padStart(2, '0');
|
||||||
@@ -607,7 +612,7 @@ export class IntelHUD {
|
|||||||
* @returns {number} Estimated sun elevation in degrees (negative = below horizon).
|
* @returns {number} Estimated sun elevation in degrees (negative = below horizon).
|
||||||
*/
|
*/
|
||||||
_estimateSunElevation(lat, lon) {
|
_estimateSunElevation(lat, lon) {
|
||||||
const now = new Date();
|
const now = new Date(skyTime());
|
||||||
// Approximate local solar time by shifting UTC hours by longitude offset
|
// Approximate local solar time by shifting UTC hours by longitude offset
|
||||||
const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15;
|
const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15;
|
||||||
const solarNoon = 12;
|
const solarNoon = 12;
|
||||||
|
|||||||
@@ -3223,5 +3223,8 @@
|
|||||||
"Audio": "الصوت",
|
"Audio": "الصوت",
|
||||||
"UTILITIES": "الأدوات",
|
"UTILITIES": "الأدوات",
|
||||||
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
|
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
|
||||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة"
|
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||||
|
"World time": "وقت العالم",
|
||||||
|
"Back to live": "العودة إلى البث المباشر",
|
||||||
|
"Live": "مباشر"
|
||||||
}
|
}
|
||||||
@@ -2943,5 +2943,8 @@
|
|||||||
"Audio": "Audio",
|
"Audio": "Audio",
|
||||||
"UTILITIES": "WERKZEUGE",
|
"UTILITIES": "WERKZEUGE",
|
||||||
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
|
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
|
||||||
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien"
|
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
|
||||||
|
"World time": "Weltzeit",
|
||||||
|
"Back to live": "Zurück zu live",
|
||||||
|
"Live": "Live"
|
||||||
}
|
}
|
||||||
@@ -3013,5 +3013,8 @@
|
|||||||
"Right panels": "Paneles derechos",
|
"Right panels": "Paneles derechos",
|
||||||
"UTILITIES": "UTILIDADES",
|
"UTILITIES": "UTILIDADES",
|
||||||
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
|
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
|
||||||
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos"
|
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
|
||||||
|
"World time": "Hora mundial",
|
||||||
|
"Back to live": "Volver al directo",
|
||||||
|
"Live": "En directo"
|
||||||
}
|
}
|
||||||
@@ -3013,5 +3013,8 @@
|
|||||||
"Audio": "Audio",
|
"Audio": "Audio",
|
||||||
"UTILITIES": "OUTILS",
|
"UTILITIES": "OUTILS",
|
||||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers"
|
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||||
|
"World time": "Heure du monde",
|
||||||
|
"Back to live": "Revenir au direct",
|
||||||
|
"Live": "Direct"
|
||||||
}
|
}
|
||||||
@@ -3013,5 +3013,8 @@
|
|||||||
"Audio": "Audio",
|
"Audio": "Audio",
|
||||||
"UTILITIES": "STRUMENTI",
|
"UTILITIES": "STRUMENTI",
|
||||||
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
|
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
|
||||||
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file"
|
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
|
||||||
|
"World time": "Ora del mondo",
|
||||||
|
"Back to live": "Torna alla diretta",
|
||||||
|
"Live": "Diretta"
|
||||||
}
|
}
|
||||||
@@ -2873,5 +2873,8 @@
|
|||||||
"Audio": "オーディオ",
|
"Audio": "オーディオ",
|
||||||
"UTILITIES": "ユーティリティ",
|
"UTILITIES": "ユーティリティ",
|
||||||
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
|
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
|
||||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル"
|
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||||
|
"World time": "世界時刻",
|
||||||
|
"Back to live": "ライブに戻る",
|
||||||
|
"Live": "ライブ"
|
||||||
}
|
}
|
||||||
@@ -3013,5 +3013,8 @@
|
|||||||
"Audio": "Áudio",
|
"Audio": "Áudio",
|
||||||
"UTILITIES": "UTILITÁRIOS",
|
"UTILITIES": "UTILITÁRIOS",
|
||||||
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
|
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
|
||||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos"
|
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
|
||||||
|
"World time": "Hora do mundo",
|
||||||
|
"Back to live": "Voltar ao vivo",
|
||||||
|
"Live": "Ao vivo"
|
||||||
}
|
}
|
||||||
@@ -3083,5 +3083,8 @@
|
|||||||
"Right panels": "Правые панели",
|
"Right panels": "Правые панели",
|
||||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||||
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
|
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
|
||||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы"
|
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||||
|
"World time": "Мировое время",
|
||||||
|
"Back to live": "Вернуться к прямому эфиру",
|
||||||
|
"Live": "Сейчас"
|
||||||
}
|
}
|
||||||
@@ -3083,5 +3083,8 @@
|
|||||||
"Audio": "Аудіо",
|
"Audio": "Аудіо",
|
||||||
"UTILITIES": "ІНСТРУМЕНТИ",
|
"UTILITIES": "ІНСТРУМЕНТИ",
|
||||||
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
|
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
|
||||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли"
|
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||||
|
"World time": "Світовий час",
|
||||||
|
"Back to live": "Повернутися до прямого ефіру",
|
||||||
|
"Live": "Зараз"
|
||||||
}
|
}
|
||||||
@@ -2873,5 +2873,8 @@
|
|||||||
"Right panels": "右侧面板",
|
"Right panels": "右侧面板",
|
||||||
"UTILITIES": "工具",
|
"UTILITIES": "工具",
|
||||||
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
|
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
|
||||||
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件"
|
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
|
||||||
|
"World time": "世界时间",
|
||||||
|
"Back to live": "返回实时",
|
||||||
|
"Live": "实时"
|
||||||
}
|
}
|
||||||
@@ -2873,5 +2873,8 @@
|
|||||||
"Right panels": "右側面板",
|
"Right panels": "右側面板",
|
||||||
"UTILITIES": "工具",
|
"UTILITIES": "工具",
|
||||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件"
|
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
|
||||||
|
"World time": "世界時間",
|
||||||
|
"Back to live": "返回即時",
|
||||||
|
"Live": "即時"
|
||||||
}
|
}
|
||||||
@@ -89,6 +89,7 @@
|
|||||||
"Landsat 8/9",
|
"Landsat 8/9",
|
||||||
"Latin",
|
"Latin",
|
||||||
"Layout",
|
"Layout",
|
||||||
|
"Live",
|
||||||
"London",
|
"London",
|
||||||
"Los Angeles",
|
"Los Angeles",
|
||||||
"MIL",
|
"MIL",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { julianAt } from '../../time/julian.js';
|
||||||
import {
|
import {
|
||||||
COURSE_HOLD_SPEED_MPS,
|
COURSE_HOLD_SPEED_MPS,
|
||||||
lerpAngleDeg,
|
lerpAngleDeg,
|
||||||
@@ -156,7 +157,7 @@ export function createMotion({
|
|||||||
const renderTime =
|
const renderTime =
|
||||||
sampledRenderTime ??
|
sampledRenderTime ??
|
||||||
Cesium.JulianDate.addSeconds(
|
Cesium.JulianDate.addSeconds(
|
||||||
Cesium.JulianDate.now(flightState._scratchRenderTime),
|
julianAt(flightState.now(), flightState._scratchRenderTime),
|
||||||
-RENDER_DELAY_SEC,
|
-RENDER_DELAY_SEC,
|
||||||
flightState._scratchRenderTime,
|
flightState._scratchRenderTime,
|
||||||
);
|
);
|
||||||
@@ -332,7 +333,7 @@ export function createMotion({
|
|||||||
const history = flightState._positionHistory.get(flightState._trackedIcao);
|
const history = flightState._positionHistory.get(flightState._trackedIcao);
|
||||||
if (!history || history.length === 0) return true;
|
if (!history || history.length === 0) return true;
|
||||||
const renderTime = Cesium.JulianDate.addSeconds(
|
const renderTime = Cesium.JulianDate.addSeconds(
|
||||||
Cesium.JulianDate.now(),
|
julianAt(flightState.now()),
|
||||||
-RENDER_DELAY_SEC,
|
-RENDER_DELAY_SEC,
|
||||||
flightState._scratchWarmupTime,
|
flightState._scratchWarmupTime,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ function fixture() {
|
|||||||
_scratchArc: {},
|
_scratchArc: {},
|
||||||
_scratchOffset: new Cesium.Cartesian3(),
|
_scratchOffset: new Cesium.Cartesian3(),
|
||||||
_scratchRenderTime: new Cesium.JulianDate(),
|
_scratchRenderTime: new Cesium.JulianDate(),
|
||||||
|
now: Date.now,
|
||||||
};
|
};
|
||||||
const motion = createMotion({
|
const motion = createMotion({
|
||||||
flightState,
|
flightState,
|
||||||
@@ -138,7 +139,7 @@ test('fleet render time is shared without resampling or mutating the supplied in
|
|||||||
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
|
assert.ok(Cesium.JulianDate.equals(renderTime, savedTime));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tracked callers still sample a fresh delayed time without a fleet epoch', (t) => {
|
test('tracked callers still sample a fresh delayed time without a fleet epoch', () => {
|
||||||
const { flightState, motion } = fixture();
|
const { flightState, motion } = fixture();
|
||||||
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
|
const start = Cesium.JulianDate.fromIso8601('2026-10-24T12:00:00Z');
|
||||||
const history = [
|
const history = [
|
||||||
@@ -156,10 +157,11 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
|
|||||||
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
|
flightState.records.data.set('tracked', { velocity: 100, true_track: 90 });
|
||||||
flightState._positionHistory.set('tracked', history);
|
flightState._positionHistory.set('tracked', history);
|
||||||
let elapsed = 60;
|
let elapsed = 60;
|
||||||
const spy = t.mock.method(Cesium.JulianDate, 'now', (result) => {
|
let reads = 0;
|
||||||
assert.equal(result, flightState._scratchRenderTime);
|
flightState.now = () => {
|
||||||
return Cesium.JulianDate.addSeconds(start, elapsed, result);
|
reads += 1;
|
||||||
});
|
return Cesium.JulianDate.toDate(start).getTime() + elapsed * 1000;
|
||||||
|
};
|
||||||
const out = new Cesium.Cartesian3();
|
const out = new Cesium.Cartesian3();
|
||||||
close(
|
close(
|
||||||
motion._deadReckon('tracked', out),
|
motion._deadReckon('tracked', out),
|
||||||
@@ -180,5 +182,5 @@ test('tracked callers still sample a fresh delayed time without a fleet epoch',
|
|||||||
new Cesium.Cartesian3(),
|
new Cesium.Cartesian3(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.equal(spy.mock.callCount(), 2);
|
assert.equal(reads, 2);
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { julianAt } from '../../time/julian.js';
|
||||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||||
@@ -863,7 +864,7 @@ export function createRendering({
|
|||||||
flightState._lastFleetTickMs = nowMs;
|
flightState._lastFleetTickMs = nowMs;
|
||||||
// Every contact in this pass shares the same delayed display instant.
|
// Every contact in this pass shares the same delayed display instant.
|
||||||
const renderTime = Cesium.JulianDate.addSeconds(
|
const renderTime = Cesium.JulianDate.addSeconds(
|
||||||
Cesium.JulianDate.now(flightState._scratchFleetRenderTime),
|
julianAt(flightState.now(), flightState._scratchFleetRenderTime),
|
||||||
-RENDER_DELAY_SEC,
|
-RENDER_DELAY_SEC,
|
||||||
flightState._scratchFleetRenderTime,
|
flightState._scratchFleetRenderTime,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FlightRecords } from './records.js';
|
import { FlightRecords } from './records.js';
|
||||||
import { createFlightFeed } from './ingestion.js';
|
import { createFlightFeed } from './ingestion.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { worldTimeFor } from '../../time/worldClock.js';
|
||||||
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
import { ENRICH_AMBIENT_BUDGET_CEIL } from './policy.js';
|
||||||
|
|
||||||
export function createFlightState({ source, services }) {
|
export function createFlightState({ source, services }) {
|
||||||
@@ -12,6 +13,8 @@ export function createFlightState({ source, services }) {
|
|||||||
...services.groundFloor,
|
...services.groundFloor,
|
||||||
});
|
});
|
||||||
flightState._cullPositions = new Map();
|
flightState._cullPositions = new Map();
|
||||||
|
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||||
|
flightState.now = worldTimeFor('flights');
|
||||||
|
|
||||||
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
|
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
|
||||||
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
|
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { julianAt } from '../../time/julian.js';
|
||||||
import { formatNumber, t } from '../../i18n/index.js';
|
import { formatNumber, t } from '../../i18n/index.js';
|
||||||
import { unitLabel } from '../../ui/unitLabels.js';
|
import { unitLabel } from '../../ui/unitLabels.js';
|
||||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||||
@@ -383,7 +384,7 @@ export function createTracking({
|
|||||||
// would draw the trail in front of the plane. They join the trail via _appendTrailFix
|
// would draw the trail in front of the plane. They join the trail via _appendTrailFix
|
||||||
// as they age past the delay.
|
// as they age past the delay.
|
||||||
const seedRenderTime = Cesium.JulianDate.addSeconds(
|
const seedRenderTime = Cesium.JulianDate.addSeconds(
|
||||||
Cesium.JulianDate.now(),
|
julianAt(flightState.now()),
|
||||||
-RENDER_DELAY_SEC,
|
-RENDER_DELAY_SEC,
|
||||||
flightState._scratchWarmupTime,
|
flightState._scratchWarmupTime,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export function createGnssIntegrityLayer({
|
|||||||
source,
|
source,
|
||||||
viewAnchor = defaultGnssViewAnchor,
|
viewAnchor = defaultGnssViewAnchor,
|
||||||
now = () => Date.now(),
|
now = () => Date.now(),
|
||||||
|
// The end of the window shown; retries and receipt times stay on `now`.
|
||||||
|
worldNow = now,
|
||||||
monotonicNow = () => performance.now(),
|
monotonicNow = () => performance.now(),
|
||||||
windowMs: requestedWindowMs,
|
windowMs: requestedWindowMs,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
@@ -163,7 +165,7 @@ export function createGnssIntegrityLayer({
|
|||||||
|
|
||||||
/** Age the store without new evidence, so expired cells leave the globe. */
|
/** Age the store without new evidence, so expired cells leave the globe. */
|
||||||
function expire() {
|
function expire() {
|
||||||
pruneGnssObservations(_observations, now(), { windowMs });
|
pruneGnssObservations(_observations, worldNow(), { windowMs });
|
||||||
_cells = binGnssCells(_observations);
|
_cells = binGnssCells(_observations);
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -26,7 +26,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
*/
|
*/
|
||||||
getAnalystRecords(maxCount = Infinity) {
|
getAnalystRecords(maxCount = Infinity) {
|
||||||
if (!layerState._enabled) return [];
|
if (!layerState._enabled) return [];
|
||||||
const now = Date.now();
|
const now = layerState.now();
|
||||||
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const launch of layerState._launches || []) {
|
for (const launch of layerState._launches || []) {
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ export function createIngestion({
|
|||||||
layerState._lastUpdate = Date.now();
|
layerState._lastUpdate = Date.now();
|
||||||
layerState._lastError = null;
|
layerState._lastError = null;
|
||||||
layerState._stale = stale === true;
|
layerState._stale = stale === true;
|
||||||
|
layerState.coverageChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||||
layerState._lastError = error.message;
|
layerState._lastError = error.message;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function createLaunchPad({
|
|||||||
}
|
}
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
|
if (nowMs - layerState._lastMissionRingRotationMs >= 1000) {
|
||||||
layerState._missionRingDate.setTime(nowMs);
|
layerState._missionRingDate.setTime(layerState.orbitNow());
|
||||||
parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
|
parts.orbitRendering.updateMissionOrbitPrimitiveFrames(
|
||||||
layerState._missionRingDate,
|
layerState._missionRingDate,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||||
|
import { worldClock } from '../../time/worldClock.js';
|
||||||
|
|
||||||
const HOVER_PICK_INTERVAL_MS = 120;
|
const HOVER_PICK_INTERVAL_MS = 120;
|
||||||
|
|
||||||
@@ -119,6 +120,21 @@ export function createLifecycle({
|
|||||||
}) {
|
}) {
|
||||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||||
let hover = null;
|
let hover = null;
|
||||||
|
// While on, the launches loaded span the time from the first to the last,
|
||||||
|
// each one a mark. Registered once; off, the layer has no time to show, and
|
||||||
|
// each step tells the clock once it has settled.
|
||||||
|
worldClock.register({
|
||||||
|
id: 'rocket-launches',
|
||||||
|
coverage: () => {
|
||||||
|
if (!layerState._enabled) return null;
|
||||||
|
const marks = layerState._launches
|
||||||
|
.map((launch) => Date.parse(launch.launchTime))
|
||||||
|
.filter(Number.isFinite)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
if (!marks.length) return null;
|
||||||
|
return { start: marks[0], end: marks.at(-1), marks };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const methods = {
|
const methods = {
|
||||||
init(viewer) {
|
init(viewer) {
|
||||||
@@ -194,6 +210,7 @@ export function createLifecycle({
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
layerState.coverageChanged();
|
||||||
},
|
},
|
||||||
|
|
||||||
async _enableBody() {
|
async _enableBody() {
|
||||||
@@ -231,6 +248,7 @@ export function createLifecycle({
|
|||||||
parts.overlays.clearMissionOverlaySources();
|
parts.overlays.clearMissionOverlaySources();
|
||||||
await parts.ingestion.restoreSatelliteDependency();
|
await parts.ingestion.restoreSatelliteDependency();
|
||||||
parts.panel.renderMissionPanel();
|
parts.panel.renderMissionPanel();
|
||||||
|
layerState.coverageChanged();
|
||||||
},
|
},
|
||||||
|
|
||||||
async destroy(viewer) {
|
async destroy(viewer) {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function createOrbitRendering({
|
|||||||
collection.show = missionOrbitPrimitiveVisible(launch.id);
|
collection.show = missionOrbitPrimitiveVisible(launch.id);
|
||||||
orbitFrameModelMatrix(
|
orbitFrameModelMatrix(
|
||||||
satelliteTrack.gmstAtBake,
|
satelliteTrack.gmstAtBake,
|
||||||
new Date(),
|
new Date(layerState.orbitNow()),
|
||||||
collection.modelMatrix,
|
collection.modelMatrix,
|
||||||
);
|
);
|
||||||
layerState._viewer.scene.primitives.add(collection);
|
layerState._viewer.scene.primitives.add(collection);
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ export function createRendering({
|
|||||||
orbitCurrent.latitude,
|
orbitCurrent.latitude,
|
||||||
orbitCurrent.altitude,
|
orbitCurrent.altitude,
|
||||||
);
|
);
|
||||||
const liveTime = new Date();
|
const liveTime = new Date(layerState.orbitNow());
|
||||||
const liveTelemetry = { speedMps: null };
|
const liveTelemetry = { speedMps: null };
|
||||||
layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
|
layerState._satelliteTelemetry.set(launch.id, liveTelemetry);
|
||||||
let liveFrameNumber = -1;
|
let liveFrameNumber = -1;
|
||||||
@@ -405,7 +405,7 @@ export function createRendering({
|
|||||||
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
|
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
|
||||||
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
|
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
|
||||||
liveFrameNumber = frameNumber;
|
liveFrameNumber = frameNumber;
|
||||||
const nowMs = Date.now();
|
const nowMs = layerState.orbitNow();
|
||||||
liveTime.setTime(nowMs);
|
liveTime.setTime(nowMs);
|
||||||
if (satelliteTrack) {
|
if (satelliteTrack) {
|
||||||
const propagated =
|
const propagated =
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
|
||||||
|
|
||||||
export function createState({ services }) {
|
export function createState({ services }) {
|
||||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
||||||
@@ -147,6 +148,18 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
state._missionRingDate = new Date(0);
|
state._missionRingDate = new Date(0);
|
||||||
|
|
||||||
|
/** The time the launch window ends at, in epoch ms. */
|
||||||
|
|
||||||
|
state.now = worldTimeFor('rocket-launches');
|
||||||
|
|
||||||
|
/** A launched mission's satellite flies on the satellites layer's time. */
|
||||||
|
|
||||||
|
state.orbitNow = worldTimeFor('satellites');
|
||||||
|
|
||||||
|
/** The launches loaded changed, and with them the time they cover. */
|
||||||
|
|
||||||
|
state.coverageChanged = () => worldClock.coverageChanged();
|
||||||
|
|
||||||
state._missionOrbitPatternRegistered = false;
|
state._missionOrbitPatternRegistered = false;
|
||||||
|
|
||||||
state._pathDistanceCache = new WeakMap();
|
state._pathDistanceCache = new WeakMap();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { julianAt } from '../../time/julian.js';
|
||||||
import {
|
import {
|
||||||
lerpAngleDeg,
|
lerpAngleDeg,
|
||||||
speedRamp,
|
speedRamp,
|
||||||
@@ -67,7 +68,7 @@ export function createMotion({
|
|||||||
// Render one poll interval behind real time so we interpolate between
|
// Render one poll interval behind real time so we interpolate between
|
||||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||||
const renderTime = Cesium.JulianDate.addSeconds(
|
const renderTime = Cesium.JulianDate.addSeconds(
|
||||||
Cesium.JulianDate.now(),
|
julianAt(flightState.now()),
|
||||||
-RENDER_DELAY_SEC,
|
-RENDER_DELAY_SEC,
|
||||||
flightState._scratchRenderTime,
|
flightState._scratchRenderTime,
|
||||||
);
|
);
|
||||||
@@ -213,7 +214,7 @@ export function createMotion({
|
|||||||
const history = flightState._positionHistory.get(flightState._trackedIcao);
|
const history = flightState._positionHistory.get(flightState._trackedIcao);
|
||||||
if (!history || history.length === 0) return true;
|
if (!history || history.length === 0) return true;
|
||||||
const renderTime = Cesium.JulianDate.addSeconds(
|
const renderTime = Cesium.JulianDate.addSeconds(
|
||||||
Cesium.JulianDate.now(),
|
julianAt(flightState.now()),
|
||||||
-RENDER_DELAY_SEC,
|
-RENDER_DELAY_SEC,
|
||||||
flightState._scratchWarmupTime,
|
flightState._scratchWarmupTime,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MilitaryFlightRecords } from './records.js';
|
import { MilitaryFlightRecords } from './records.js';
|
||||||
import { createMilitaryFeed } from './ingestion.js';
|
import { createMilitaryFeed } from './ingestion.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { worldTimeFor } from '../../time/worldClock.js';
|
||||||
|
|
||||||
export function createFlightState({ source, services }) {
|
export function createFlightState({ source, services }) {
|
||||||
const { createGroundSnap } = services.groundSnap;
|
const { createGroundSnap } = services.groundSnap;
|
||||||
@@ -11,6 +12,8 @@ export function createFlightState({ source, services }) {
|
|||||||
...services.groundFloor,
|
...services.groundFloor,
|
||||||
});
|
});
|
||||||
flightState._cullPositions = new Map();
|
flightState._cullPositions = new Map();
|
||||||
|
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||||
|
flightState.now = worldTimeFor('military');
|
||||||
|
|
||||||
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
||||||
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { julianAt } from '../../time/julian.js';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
import { nextCockpitNearContacts } from '../../data/cockpitAirLod.js';
|
||||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||||
@@ -500,7 +501,7 @@ export function createTracking({
|
|||||||
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
|
// newest ~RENDER_DELAY_SEC of fixes are AHEAD of the displayed icon; including them
|
||||||
// would draw the trail in front of the plane. They join via _appendTrailFix as they age.
|
// would draw the trail in front of the plane. They join via _appendTrailFix as they age.
|
||||||
const seedRenderTime = Cesium.JulianDate.addSeconds(
|
const seedRenderTime = Cesium.JulianDate.addSeconds(
|
||||||
Cesium.JulianDate.now(),
|
julianAt(flightState.now()),
|
||||||
-RENDER_DELAY_SEC,
|
-RENDER_DELAY_SEC,
|
||||||
flightState._scratchWarmupTime,
|
flightState._scratchWarmupTime,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
1,
|
1,
|
||||||
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
|
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
|
||||||
);
|
);
|
||||||
const now = new Date();
|
const now = new Date(layerState.now());
|
||||||
for (let i = 0; i < perFrame; i++) {
|
for (let i = 0; i < perFrame; i++) {
|
||||||
if (layerState._denseCursor >= layerState._denseIds.length)
|
if (layerState._denseCursor >= layerState._denseIds.length)
|
||||||
layerState._denseCursor = 0;
|
layerState._denseCursor = 0;
|
||||||
@@ -97,7 +97,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
const entries = parts.orbits.parseTLE(text);
|
const entries = parts.orbits.parseTLE(text);
|
||||||
const style = POINT_STYLES.dense;
|
const style = POINT_STYLES.dense;
|
||||||
const now = new Date();
|
const now = new Date(layerState.now());
|
||||||
let added = 0;
|
let added = 0;
|
||||||
|
|
||||||
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
|
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
|
||||||
@@ -165,6 +165,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
layerState._count = layerState._points.size;
|
layerState._count = layerState._points.size;
|
||||||
layerState._catalogRevision++;
|
layerState._catalogRevision++;
|
||||||
|
layerState.coverageChanged();
|
||||||
layerState._denseStatus = 'ready';
|
layerState._denseStatus = 'ready';
|
||||||
console.log(
|
console.log(
|
||||||
`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`,
|
`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`,
|
||||||
@@ -232,6 +233,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
layerState._denseCursor = 0;
|
layerState._denseCursor = 0;
|
||||||
layerState._count = layerState._points.size;
|
layerState._count = layerState._points.size;
|
||||||
layerState._catalogRevision++;
|
layerState._catalogRevision++;
|
||||||
|
layerState.coverageChanged();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
_abortActiveUpdates,
|
_abortActiveUpdates,
|
||||||
|
|||||||
Vendored
+4
-1
@@ -190,7 +190,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
if (noradId === null) return null;
|
if (noradId === null) return null;
|
||||||
|
|
||||||
const sat = layerState._catalog.get(noradId);
|
const sat = layerState._catalog.get(noradId);
|
||||||
const pos = parts.orbits.propagatePosition(sat.satrec, new Date());
|
const pos = parts.orbits.propagatePosition(
|
||||||
|
sat.satrec,
|
||||||
|
new Date(layerState.now()),
|
||||||
|
);
|
||||||
if (!pos) return null;
|
if (!pos) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function createIngestion({
|
|||||||
layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog
|
layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog
|
||||||
parts.labels._syncIssOverlay();
|
parts.labels._syncIssOverlay();
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date(layerState.now());
|
||||||
|
|
||||||
// Process all TLE entries in CATALOG_GROUPS order
|
// Process all TLE entries in CATALOG_GROUPS order
|
||||||
const allEntries = [];
|
const allEntries = [];
|
||||||
@@ -195,6 +195,7 @@ export function createIngestion({
|
|||||||
// the rebuild must not block on that.
|
// the rebuild must not block on that.
|
||||||
void parts.tracking._reconcileTrackedSubjectContext();
|
void parts.tracking._reconcileTrackedSubjectContext();
|
||||||
parts.tracking._applyPendingTrackingRestore();
|
parts.tracking._applyPendingTrackingRestore();
|
||||||
|
layerState.coverageChanged();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||||
throw new DOMException('Satellite update aborted', 'AbortError');
|
throw new DOMException('Satellite update aborted', 'AbortError');
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ function ingestionFor(catalogText) {
|
|||||||
_denseLoadToken: 0,
|
_denseLoadToken: 0,
|
||||||
_catalogRevision: 0,
|
_catalogRevision: 0,
|
||||||
_params: { catalog: 'core', showOrbits: false },
|
_params: { catalog: 'core', showOrbits: false },
|
||||||
|
now: Date.now,
|
||||||
|
coverageChanged() {},
|
||||||
};
|
};
|
||||||
const parts = {
|
const parts = {
|
||||||
orbits: {
|
orbits: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { ISS_OVERLAY_SOURCE_ID } from './policy.js';
|
import { ISS_OVERLAY_SOURCE_ID, SATELLITE_COVERAGE_MS } from './policy.js';
|
||||||
|
import { worldClock } from '../../time/worldClock.js';
|
||||||
|
|
||||||
export function createLifecycle({
|
export function createLifecycle({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
@@ -8,6 +9,20 @@ export function createLifecycle({
|
|||||||
source,
|
source,
|
||||||
}) {
|
}) {
|
||||||
const { clearFocusTarget } = services.focus;
|
const { clearFocusTarget } = services.focus;
|
||||||
|
// While on with a catalog, satellites can be shown at any time in reach of
|
||||||
|
// their element sets. Registered once; off, the layer has no time to show,
|
||||||
|
// and each step tells the clock once it has settled.
|
||||||
|
worldClock.register({
|
||||||
|
id: 'satellites',
|
||||||
|
coverage: () => {
|
||||||
|
if (!layerState._enabled || !layerState._catalog.size) return null;
|
||||||
|
const now = worldClock.wallNow();
|
||||||
|
return {
|
||||||
|
start: now - SATELLITE_COVERAGE_MS,
|
||||||
|
end: now + SATELLITE_COVERAGE_MS,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||||
|
|
||||||
@@ -46,6 +61,7 @@ export function createLifecycle({
|
|||||||
layerState._denseStatus = 'idle';
|
layerState._denseStatus = 'idle';
|
||||||
layerState._denseError = null;
|
layerState._denseError = null;
|
||||||
layerState._catalogRevision++;
|
layerState._catalogRevision++;
|
||||||
|
layerState.coverageChanged();
|
||||||
|
|
||||||
// Point primitives for satellite dots
|
// Point primitives for satellite dots
|
||||||
layerState._pointCollection = new Cesium.PointPrimitiveCollection();
|
layerState._pointCollection = new Cesium.PointPrimitiveCollection();
|
||||||
@@ -92,6 +108,7 @@ export function createLifecycle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
parts.tracking._applyPendingTrackingRestore();
|
parts.tracking._applyPendingTrackingRestore();
|
||||||
|
layerState.coverageChanged();
|
||||||
},
|
},
|
||||||
|
|
||||||
disable(viewer) {
|
disable(viewer) {
|
||||||
@@ -119,6 +136,7 @@ export function createLifecycle({
|
|||||||
layerState._preRenderListener();
|
layerState._preRenderListener();
|
||||||
layerState._preRenderListener = null;
|
layerState._preRenderListener = null;
|
||||||
}
|
}
|
||||||
|
layerState.coverageChanged();
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy(viewer) {
|
destroy(viewer) {
|
||||||
@@ -176,6 +194,7 @@ export function createLifecycle({
|
|||||||
failedGroups: [],
|
failedGroups: [],
|
||||||
};
|
};
|
||||||
layerState._viewer = null;
|
layerState._viewer = null;
|
||||||
|
layerState.coverageChanged();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
|||||||
lonDeg,
|
lonDeg,
|
||||||
minElevDeg = 10,
|
minElevDeg = 10,
|
||||||
requireVisible = false,
|
requireVisible = false,
|
||||||
fromMs = Date.now(),
|
fromMs = layerState.now(),
|
||||||
}) {
|
}) {
|
||||||
return getNextSatellitePass(ISS_NORAD, {
|
return getNextSatellitePass(ISS_NORAD, {
|
||||||
latDeg,
|
latDeg,
|
||||||
@@ -167,7 +167,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
|||||||
lonDeg,
|
lonDeg,
|
||||||
minElevDeg = 10,
|
minElevDeg = 10,
|
||||||
requireVisible = false,
|
requireVisible = false,
|
||||||
fromMs = Date.now(),
|
fromMs = layerState.now(),
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const id = Number(noradId);
|
const id = Number(noradId);
|
||||||
@@ -271,7 +271,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function orbitTrackFromRecord(name, satrec) {
|
function orbitTrackFromRecord(name, satrec) {
|
||||||
const referenceDate = new Date();
|
const referenceDate = new Date(layerState.now());
|
||||||
const current = propagatePosition(satrec, referenceDate);
|
const current = propagatePosition(satrec, referenceDate);
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export const ORBIT_PATH_STEPS = 180;
|
|||||||
// points per orbital path
|
// points per orbital path
|
||||||
|
|
||||||
export const POSITION_UPDATE_MS = 1000;
|
export const POSITION_UPDATE_MS = 1000;
|
||||||
|
/** How far either side of now SGP4 on current element sets stays meaningful. */
|
||||||
|
export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the world time may move from when an orbit ring was drawn before it
|
||||||
|
* is drawn again: the Earth's turn is followed exactly, but the orbit's plane
|
||||||
|
* drifts a few degrees a day.
|
||||||
|
*/
|
||||||
|
export const ORBIT_REBAKE_MS = 30 * 60_000;
|
||||||
// re-propagate every 1s (SGP4 is smooth at this rate)
|
// re-propagate every 1s (SGP4 is smooth at this rate)
|
||||||
|
|
||||||
export const RING_ROTATION_MS = 1000;
|
export const RING_ROTATION_MS = 1000;
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function createRecords({ state, parts }) {
|
|||||||
const limit = Number.isFinite(maxCount)
|
const limit = Number.isFinite(maxCount)
|
||||||
? Math.max(1, Math.floor(maxCount))
|
? Math.max(1, Math.floor(maxCount))
|
||||||
: 2000;
|
: 2000;
|
||||||
const now = new Date();
|
const now = new Date(state.now());
|
||||||
const result = [];
|
const result = [];
|
||||||
const emit = (noradId, sat) => {
|
const emit = (noradId, sat) => {
|
||||||
if (result.length >= limit) return false;
|
if (result.length >= limit) return false;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { gstime } from 'satellite.js';
|
import { gstime } from 'satellite.js';
|
||||||
import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js';
|
import {
|
||||||
|
ISS_NORAD,
|
||||||
|
ORBIT_REBAKE_MS,
|
||||||
|
POSITION_UPDATE_MS,
|
||||||
|
RING_ROTATION_MS,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
export function createRendering({
|
export function createRendering({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
@@ -42,7 +47,7 @@ export function createRendering({
|
|||||||
const sat = layerState._catalog.get(noradId);
|
const sat = layerState._catalog.get(noradId);
|
||||||
if (!sat || !layerState._viewer) return;
|
if (!sat || !layerState._viewer) return;
|
||||||
|
|
||||||
const bakeDate = new Date();
|
const bakeDate = new Date(layerState.now());
|
||||||
const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate);
|
const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate);
|
||||||
if (basePositions.length < 2) return;
|
if (basePositions.length < 2) return;
|
||||||
|
|
||||||
@@ -75,6 +80,8 @@ export function createRendering({
|
|||||||
|
|
||||||
layerState._orbitPaths.set(noradId, {
|
layerState._orbitPaths.set(noradId, {
|
||||||
primitive,
|
primitive,
|
||||||
|
color: pathColor,
|
||||||
|
bakeMs: bakeDate.getTime(),
|
||||||
// Same Date object as computeOrbitPath's internal fixedGmst → identical
|
// Same Date object as computeOrbitPath's internal fixedGmst → identical
|
||||||
// GMST value (gstime is pure), so delta starts at exactly 0 and the
|
// GMST value (gstime is pure), so delta starts at exactly 0 and the
|
||||||
// initial identity modelMatrix is correct until the first ring tick.
|
// initial identity modelMatrix is correct until the first ring tick.
|
||||||
@@ -113,8 +120,15 @@ export function createRendering({
|
|||||||
function _updateOrbitPathRotations(nowDate) {
|
function _updateOrbitPathRotations(nowDate) {
|
||||||
if (layerState._orbitPaths.size === 0) return;
|
if (layerState._orbitPaths.size === 0) return;
|
||||||
|
|
||||||
for (const path of layerState._orbitPaths.values()) {
|
for (const [noradId, path] of [...layerState._orbitPaths]) {
|
||||||
if (!path.primitive) continue;
|
if (!path.primitive) continue;
|
||||||
|
// The rotation keeps the ring under the dot for minutes; the plane
|
||||||
|
// itself precesses, so a world time moved far from the bake re-bakes.
|
||||||
|
if (Math.abs(nowDate.getTime() - path.bakeMs) > ORBIT_REBAKE_MS) {
|
||||||
|
_hideOrbitPath(noradId);
|
||||||
|
_showOrbitPath(noradId, path.color);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
parts.orbits.orbitFrameModelMatrix(
|
parts.orbits.orbitFrameModelMatrix(
|
||||||
path.gmstAtBake,
|
path.gmstAtBake,
|
||||||
nowDate,
|
nowDate,
|
||||||
@@ -143,7 +157,7 @@ export function createRendering({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function _propagateAll() {
|
function _propagateAll() {
|
||||||
const now = new Date();
|
const now = new Date(layerState.now());
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
|
||||||
for (const [noradId, sat] of layerState._catalog) {
|
for (const [noradId, sat] of layerState._catalog) {
|
||||||
@@ -211,7 +225,7 @@ export function createRendering({
|
|||||||
layerState._params.showOrbits &&
|
layerState._params.showOrbits &&
|
||||||
now - layerState._lastRingRotation >= RING_ROTATION_MS
|
now - layerState._lastRingRotation >= RING_ROTATION_MS
|
||||||
) {
|
) {
|
||||||
_updateOrbitPathRotations(new Date(now));
|
_updateOrbitPathRotations(new Date(layerState.now()));
|
||||||
layerState._lastRingRotation = now;
|
layerState._lastRingRotation = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
|
||||||
|
|
||||||
export function createState({ services }) {
|
export function createState({ services }) {
|
||||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
||||||
@@ -151,6 +152,14 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
state._trackedFrameNowForTest = null;
|
state._trackedFrameNowForTest = null;
|
||||||
|
|
||||||
|
/** The time satellites are propagated to, in epoch ms. */
|
||||||
|
|
||||||
|
state.now = worldTimeFor('satellites');
|
||||||
|
|
||||||
|
/** The catalog changed, so the time satellites can be shown at may have. */
|
||||||
|
|
||||||
|
state.coverageChanged = () => worldClock.coverageChanged();
|
||||||
|
|
||||||
// Scratch variables
|
// Scratch variables
|
||||||
|
|
||||||
state._scratchCartesian = new Cesium.Cartesian3();
|
state._scratchCartesian = new Cesium.Cartesian3();
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
|||||||
sat.satrec,
|
sat.satrec,
|
||||||
layerState._trackedFrameNowForTest
|
layerState._trackedFrameNowForTest
|
||||||
? new Date(layerState._trackedFrameNowForTest())
|
? new Date(layerState._trackedFrameNowForTest())
|
||||||
: new Date(),
|
: new Date(layerState.now()),
|
||||||
);
|
);
|
||||||
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
|
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
|
||||||
layerState._trackedFrameGeo = pos;
|
layerState._trackedFrameGeo = pos;
|
||||||
@@ -434,7 +434,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
|||||||
// Comfortable tracking landing: ~726 km back for LEO (user-validated
|
// Comfortable tracking landing: ~726 km back for LEO (user-validated
|
||||||
// "slightly zoomed out" framing — no stutter, label reads cleanly), scaled
|
// "slightly zoomed out" framing — no stutter, label reads cleanly), scaled
|
||||||
// up for MEO/GEO so the camera doesn't land on top of a high-orbit dot.
|
// up for MEO/GEO so the camera doesn't land on top of a high-orbit dot.
|
||||||
const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date());
|
const initialPos = parts.orbits.propagatePosition(
|
||||||
|
sat.satrec,
|
||||||
|
new Date(layerState.now()),
|
||||||
|
);
|
||||||
const viewScale =
|
const viewScale =
|
||||||
initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M
|
initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M
|
||||||
? TRACK_VIEW_FROM_HIGH_SCALE
|
? TRACK_VIEW_FROM_HIGH_SCALE
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createSelection } from './selection.js';
|
|||||||
import { createViewerHost } from './viewerHost.js';
|
import { createViewerHost } from './viewerHost.js';
|
||||||
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
|
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
|
||||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||||
|
import { worldTimeFor } from '../../time/worldClock.js';
|
||||||
import { decodeParams, encodeParams } from './params.js';
|
import { decodeParams, encodeParams } from './params.js';
|
||||||
import {
|
import {
|
||||||
composeUIState,
|
composeUIState,
|
||||||
@@ -41,6 +42,9 @@ const GROUND_RETRY_MS = 1000;
|
|||||||
* a time.
|
* a time.
|
||||||
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
||||||
*/
|
*/
|
||||||
|
// "Since N days" counts back from the world time.
|
||||||
|
const streetLevelTime = worldTimeFor(STREET_LEVEL_LAYER_ID);
|
||||||
|
|
||||||
export function createStreetLevelLayer({
|
export function createStreetLevelLayer({
|
||||||
providers: definitions,
|
providers: definitions,
|
||||||
services = {},
|
services = {},
|
||||||
@@ -93,7 +97,7 @@ export function createStreetLevelLayer({
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
services: state.services,
|
services: state.services,
|
||||||
/** Resolved against now, so "since N days" keeps up with the clock. */
|
/** Resolved against now, so "since N days" keeps up with the clock. */
|
||||||
getFilter: () => resolveFilter(state.filter),
|
getFilter: () => resolveFilter(state.filter, streetLevelTime()),
|
||||||
/** The layer and this provider's switch are both on. */
|
/** The layer and this provider's switch are both on. */
|
||||||
isActive: () => state.enabled && entry.on,
|
isActive: () => state.enabled && entry.on,
|
||||||
notify,
|
notify,
|
||||||
@@ -356,7 +360,7 @@ export function createStreetLevelLayer({
|
|||||||
const filter = normalizeFilter(next, state.filter);
|
const filter = normalizeFilter(next, state.filter);
|
||||||
if (sameFilter(filter, state.filter)) return;
|
if (sameFilter(filter, state.filter)) return;
|
||||||
state.filter = filter;
|
state.filter = filter;
|
||||||
const resolved = resolveFilter(filter);
|
const resolved = resolveFilter(filter, streetLevelTime());
|
||||||
for (const entry of state.providers.values())
|
for (const entry of state.providers.values())
|
||||||
entry.instance.setFilter(resolved);
|
entry.instance.setFilter(resolved);
|
||||||
notify();
|
notify();
|
||||||
|
|||||||
@@ -3927,6 +3927,7 @@ test('parked transit reprojects held and reported courses at the bounded camera
|
|||||||
_rotationRevision: -1,
|
_rotationRevision: -1,
|
||||||
_cameraRevision: 0,
|
_cameraRevision: 0,
|
||||||
_rotationDirty: false,
|
_rotationDirty: false,
|
||||||
|
now: () => Date.now(),
|
||||||
};
|
};
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
const rendering = createRendering({
|
const rendering = createRendering({
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function createRendering({ state, services, parts }) {
|
|||||||
}
|
}
|
||||||
function sampleIdle(entry) {
|
function sampleIdle(entry) {
|
||||||
if (!state._moving.has(entry)) {
|
if (!state._moving.has(entry)) {
|
||||||
updatePlayback(entry, Date.now(), performance.now());
|
updatePlayback(entry, state.now(), performance.now());
|
||||||
placeSample(entry);
|
placeSample(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ export function createRendering({ state, services, parts }) {
|
|||||||
() => {
|
() => {
|
||||||
entry.wakeTimer = null;
|
entry.wakeTimer = null;
|
||||||
if (!state._enabled || !state._vehicles.has(entry.key)) return;
|
if (!state._enabled || !state._vehicles.has(entry.key)) return;
|
||||||
updatePlayback(entry, Date.now(), performance.now());
|
updatePlayback(entry, state.now(), performance.now());
|
||||||
placeSample(entry);
|
placeSample(entry);
|
||||||
schedulePlayback(entry);
|
schedulePlayback(entry);
|
||||||
syncRenderHold();
|
syncRenderHold();
|
||||||
@@ -388,7 +388,7 @@ export function createRendering({ state, services, parts }) {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (!state._enabled) return;
|
if (!state._enabled) return;
|
||||||
const now = Date.now();
|
const now = state.now();
|
||||||
if (state._vehicles.size === 0) {
|
if (state._vehicles.size === 0) {
|
||||||
state._moving.clear();
|
state._moving.clear();
|
||||||
syncRenderHold();
|
syncRenderHold();
|
||||||
@@ -424,7 +424,7 @@ export function createRendering({ state, services, parts }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parts.trails.update();
|
parts.trails.update();
|
||||||
refreshRotations(now);
|
refreshRotations(Date.now());
|
||||||
const selected = state._vehicles.get(state._selectedKey);
|
const selected = state._vehicles.get(state._selectedKey);
|
||||||
if (selected && state._visible.has(selected)) {
|
if (selected && state._visible.has(selected)) {
|
||||||
selected.marker.rotation = Number.isFinite(selected.courseDeg)
|
selected.marker.rotation = Number.isFinite(selected.courseDeg)
|
||||||
@@ -489,7 +489,7 @@ export function createRendering({ state, services, parts }) {
|
|||||||
const noSample = !entry.sample || !Number.isFinite(entry.sample.lat);
|
const noSample = !entry.sample || !Number.isFinite(entry.sample.lat);
|
||||||
if (entry.track?.count && (!state._visible.has(entry) || noSample)) {
|
if (entry.track?.count && (!state._visible.has(entry) || noSample)) {
|
||||||
entry.sample ||= {};
|
entry.sample ||= {};
|
||||||
updatePlayback(entry, now, performance.now());
|
updatePlayback(entry, state.now(), performance.now());
|
||||||
}
|
}
|
||||||
sphere.center =
|
sphere.center =
|
||||||
state._visible.has(entry) || !entry.sample
|
state._visible.has(entry) || !entry.sample
|
||||||
@@ -557,7 +557,7 @@ export function createRendering({ state, services, parts }) {
|
|||||||
}
|
}
|
||||||
function resumePlayback() {
|
function resumePlayback() {
|
||||||
if (!state._enabled) return;
|
if (!state._enabled) return;
|
||||||
const now = Date.now(),
|
const now = state.now(),
|
||||||
mono = performance.now();
|
mono = performance.now();
|
||||||
const entries = new Set(state._visible);
|
const entries = new Set(state._visible);
|
||||||
const selected = state._vehicles.get(state._selectedKey);
|
const selected = state._vehicles.get(state._selectedKey);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createHistoryBudget } from '../../data/contactPlayback.js';
|
import { createHistoryBudget } from '../../data/contactPlayback.js';
|
||||||
|
import { worldTimeFor } from '../../time/worldClock.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One Transit layer's mutable scene state. Every field lives on the instance,
|
* One Transit layer's mutable scene state. Every field lives on the instance,
|
||||||
@@ -14,6 +15,8 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
const state = {};
|
const state = {};
|
||||||
state._historyBudget = createHistoryBudget();
|
state._historyBudget = createHistoryBudget();
|
||||||
|
/** The time vehicles play back behind by their display delay, in epoch ms. */
|
||||||
|
state.now = worldTimeFor('transit');
|
||||||
|
|
||||||
state.DEFAULT_OVERLAY_HOST = Object.freeze({
|
state.DEFAULT_OVERLAY_HOST = Object.freeze({
|
||||||
setEntries: setOverlayEntries,
|
setEntries: setOverlayEntries,
|
||||||
|
|||||||
+128
-17
@@ -1,19 +1,35 @@
|
|||||||
|
import { createWorldClock } from '../../time/worldClock.js';
|
||||||
|
|
||||||
export const RADAR_MAX_GAP_MS = 30 * 60_000;
|
export const RADAR_MAX_GAP_MS = 30 * 60_000;
|
||||||
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
|
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
|
||||||
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
|
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
|
||||||
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
|
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
|
||||||
|
|
||||||
/** Transient observed time. Products must honor the signal before installing a frame. */
|
/**
|
||||||
|
* Transient observed time. Products must honor the signal before installing a
|
||||||
|
* frame. Its time is the world clock's: latest while the world is live,
|
||||||
|
* history at the world's time otherwise. Replay steps the observed frames and
|
||||||
|
* moves the world clock as it goes, as the world clock's driver.
|
||||||
|
*/
|
||||||
export function createWeatherClock({
|
export function createWeatherClock({
|
||||||
now = Date.now,
|
now = Date.now,
|
||||||
setTimeout = globalThis.setTimeout,
|
setTimeout = globalThis.setTimeout,
|
||||||
clearTimeout = globalThis.clearTimeout,
|
clearTimeout = globalThis.clearTimeout,
|
||||||
|
world = createWorldClock({ now }),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const registry = new Map();
|
const registry = new Map();
|
||||||
const listeners = new Set();
|
const listeners = new Set();
|
||||||
let mode = 'latest';
|
let mode = 'latest';
|
||||||
let target = null;
|
let target = null;
|
||||||
let playing = false;
|
let playing = false;
|
||||||
|
// The world time last applied; while this clock moves the world, the
|
||||||
|
// transition its move started.
|
||||||
|
let seen = 'latest';
|
||||||
|
let moving = false;
|
||||||
|
let moved = null;
|
||||||
|
const releases = new Map();
|
||||||
|
// Attached at the end, once the API exists.
|
||||||
|
let driver = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let pending = null;
|
let pending = null;
|
||||||
@@ -65,11 +81,32 @@ export function createWeatherClock({
|
|||||||
timer = null;
|
timer = null;
|
||||||
};
|
};
|
||||||
const canPlay = () => getTimeline().length >= 2;
|
const canPlay = () => getTimeline().length >= 2;
|
||||||
|
const stopPlaying = () => {
|
||||||
|
if (!playing) return;
|
||||||
|
playing = false;
|
||||||
|
driver?.driven(false);
|
||||||
|
};
|
||||||
|
// Move the world clock; the change comes back through followWorld. Returns
|
||||||
|
// the transition the move started, if it started one.
|
||||||
|
const move = (fn) => {
|
||||||
|
const outer = [moving, moved];
|
||||||
|
moving = true;
|
||||||
|
moved = null;
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
return moved;
|
||||||
|
} finally {
|
||||||
|
[moving, moved] = outer;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selections = () =>
|
||||||
|
[...registry.values()].map(({ product }) => selectedFor(product)).join();
|
||||||
|
let applied = null;
|
||||||
function schedule() {
|
function schedule() {
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
if (!playing || destroyed || pending) return;
|
if (!playing || destroyed || pending) return;
|
||||||
if (!canPlay()) {
|
if (!canPlay()) {
|
||||||
playing = false;
|
stopPlaying();
|
||||||
notify();
|
notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -92,7 +129,8 @@ export function createWeatherClock({
|
|||||||
if (destroyed) return Promise.resolve(false);
|
if (destroyed) return Promise.resolve(false);
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
const owner = ++generation;
|
const owner = ++generation;
|
||||||
if (!canPlay()) playing = false;
|
if (!canPlay()) stopPlaying();
|
||||||
|
applied = selections();
|
||||||
for (const entry of registry.values()) entry.controller?.abort();
|
for (const entry of registry.values()) entry.controller?.abort();
|
||||||
// Start all products together; a rejection must not skip the settle barrier.
|
// Start all products together; a rejection must not skip the settle barrier.
|
||||||
const tasks = [...registry.values()].map((entry) => {
|
const tasks = [...registry.values()].map((entry) => {
|
||||||
@@ -107,12 +145,13 @@ export function createWeatherClock({
|
|||||||
const result = Promise.allSettled(tasks).then(() => {
|
const result = Promise.allSettled(tasks).then(() => {
|
||||||
if (destroyed || owner !== generation) return false;
|
if (destroyed || owner !== generation) return false;
|
||||||
pending = null;
|
pending = null;
|
||||||
if (!canPlay()) playing = false;
|
if (!canPlay()) stopPlaying();
|
||||||
notify();
|
notify();
|
||||||
schedule();
|
schedule();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
pending = result;
|
pending = result;
|
||||||
|
if (moving) moved ??= result;
|
||||||
notify();
|
notify();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -123,11 +162,41 @@ export function createWeatherClock({
|
|||||||
throw new Error(`Duplicate weather product: ${product.id}`);
|
throw new Error(`Duplicate weather product: ${product.id}`);
|
||||||
const entry = { product, controller: null };
|
const entry = { product, controller: null };
|
||||||
registry.set(product.id, entry);
|
registry.set(product.id, entry);
|
||||||
|
// The product moves with the world clock, and its frames are time the
|
||||||
|
// world clock's timeline can show while it is on, suspended or not, so
|
||||||
|
// the way back to live stays on screen. Both calls notify the world's
|
||||||
|
// listeners, which may destroy this clock: the release is in place
|
||||||
|
// first, and anything acquired after a destroy is let go at once.
|
||||||
|
let unhold = null;
|
||||||
|
let uncover = null;
|
||||||
|
const release = () => {
|
||||||
|
uncover?.();
|
||||||
|
unhold?.();
|
||||||
|
uncover = unhold = null;
|
||||||
|
};
|
||||||
|
releases.set(product.id, release);
|
||||||
|
unhold = world.hold(product.id);
|
||||||
|
if (!destroyed)
|
||||||
|
uncover = world.register({
|
||||||
|
id: product.id,
|
||||||
|
coverage: () => {
|
||||||
|
const marks = timesFor(product).map((time) => Date.parse(time));
|
||||||
|
return marks.length
|
||||||
|
? { start: marks[0], end: marks.at(-1), marks }
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (destroyed) {
|
||||||
|
release();
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
void api.refresh();
|
void api.refresh();
|
||||||
return () => {
|
return () => {
|
||||||
if (registry.get(product.id) !== entry) return;
|
if (registry.get(product.id) !== entry) return;
|
||||||
entry.controller?.abort();
|
entry.controller?.abort();
|
||||||
registry.delete(product.id);
|
registry.delete(product.id);
|
||||||
|
releases.get(product.id)?.();
|
||||||
|
releases.delete(product.id);
|
||||||
void api.refresh();
|
void api.refresh();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -140,9 +209,11 @@ export function createWeatherClock({
|
|||||||
const normalized = new Date(time).toISOString();
|
const normalized = new Date(time).toISOString();
|
||||||
if (mode === 'history' && target === normalized)
|
if (mode === 'history' && target === normalized)
|
||||||
return pending ?? Promise.resolve(true);
|
return pending ?? Promise.resolve(true);
|
||||||
mode = 'history';
|
return (
|
||||||
target = normalized;
|
move(() => world.seek(Date.parse(normalized))) ??
|
||||||
return transition();
|
pending ??
|
||||||
|
Promise.resolve(true)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
step(direction) {
|
step(direction) {
|
||||||
if (destroyed || ![-1, 1].includes(direction))
|
if (destroyed || ![-1, 1].includes(direction))
|
||||||
@@ -159,10 +230,9 @@ export function createWeatherClock({
|
|||||||
},
|
},
|
||||||
latest() {
|
latest() {
|
||||||
if (destroyed) return Promise.resolve(false);
|
if (destroyed) return Promise.resolve(false);
|
||||||
mode = 'latest';
|
stopPlaying();
|
||||||
target = null;
|
if (mode === 'latest') return transition();
|
||||||
playing = false;
|
return move(() => world.goLive()) ?? pending ?? Promise.resolve(true);
|
||||||
return transition();
|
|
||||||
},
|
},
|
||||||
play() {
|
play() {
|
||||||
if (destroyed || playing || !canPlay())
|
if (destroyed || playing || !canPlay())
|
||||||
@@ -170,18 +240,28 @@ export function createWeatherClock({
|
|||||||
playing = true;
|
playing = true;
|
||||||
if (mode === 'latest') {
|
if (mode === 'latest') {
|
||||||
const timeline = getTimeline();
|
const timeline = getTimeline();
|
||||||
return api.setTarget(
|
const start =
|
||||||
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0],
|
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0];
|
||||||
|
// Playing and at the first frame in one move of the world.
|
||||||
|
return (
|
||||||
|
move(() => driver.driven(true, Date.parse(start))) ??
|
||||||
|
pending ??
|
||||||
|
Promise.resolve(true)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
driver.driven(true);
|
||||||
notify();
|
notify();
|
||||||
schedule();
|
schedule();
|
||||||
return pending ?? Promise.resolve(true);
|
return pending ?? Promise.resolve(true);
|
||||||
},
|
},
|
||||||
pause() {
|
pause() {
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
if (!playing) return;
|
if (!playing) {
|
||||||
playing = false;
|
// The world may be playing on its own; a pause or step here holds it.
|
||||||
|
if (world.isPlaying()) world.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPlaying();
|
||||||
notify();
|
notify();
|
||||||
},
|
},
|
||||||
togglePlay() {
|
togglePlay() {
|
||||||
@@ -189,8 +269,10 @@ export function createWeatherClock({
|
|||||||
},
|
},
|
||||||
refresh() {
|
refresh() {
|
||||||
if (destroyed) return Promise.resolve(false);
|
if (destroyed) return Promise.resolve(false);
|
||||||
|
// New frames, or products added or dropped.
|
||||||
|
world.coverageChanged();
|
||||||
if (!canPlay()) {
|
if (!canPlay()) {
|
||||||
playing = false;
|
stopPlaying();
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
}
|
}
|
||||||
if (mode === 'history') return transition();
|
if (mode === 'history') return transition();
|
||||||
@@ -204,15 +286,44 @@ export function createWeatherClock({
|
|||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
|
stopPlaying();
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
++generation;
|
++generation;
|
||||||
playing = false;
|
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
|
unfollowWorld();
|
||||||
|
// The last weather clock leaves the world live, as a new one expects.
|
||||||
|
if (driver.detach() === 0) world.goLive();
|
||||||
|
for (const release of releases.values()) release();
|
||||||
|
releases.clear();
|
||||||
for (const entry of registry.values()) entry.controller?.abort();
|
for (const entry of registry.values()) entry.controller?.abort();
|
||||||
registry.clear();
|
registry.clear();
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
pending = null;
|
pending = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Follow the world clock. A move of this clock's own always applies; one
|
||||||
|
// from elsewhere (a host's seek, continuous play) applies only when it
|
||||||
|
// changes the mode or a product's frame.
|
||||||
|
function followWorld(state) {
|
||||||
|
if (destroyed) return;
|
||||||
|
const nextMode = state.live ? 'latest' : 'history';
|
||||||
|
const nextTarget = state.live
|
||||||
|
? null
|
||||||
|
: new Date(Math.floor(state.timeMs)).toISOString();
|
||||||
|
const key = nextTarget ?? 'latest';
|
||||||
|
if (key === seen) return;
|
||||||
|
seen = key;
|
||||||
|
const modeChanged = nextMode !== mode;
|
||||||
|
mode = nextMode;
|
||||||
|
target = nextTarget;
|
||||||
|
if (moving || modeChanged || selections() !== applied) void transition();
|
||||||
|
else notify();
|
||||||
|
}
|
||||||
|
const unfollowWorld = world.subscribe(followWorld);
|
||||||
|
driver = world.attachDriver({
|
||||||
|
play: () => (canPlay() ? void api.play() : false),
|
||||||
|
pause: () => api.pause(),
|
||||||
|
});
|
||||||
|
followWorld(world.getState());
|
||||||
return api;
|
return api;
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||||
LIGHTNING_MAX_GAP_MS,
|
LIGHTNING_MAX_GAP_MS,
|
||||||
} from './clock.js';
|
} from './clock.js';
|
||||||
|
import { createWorldClock } from '../../time/worldClock.js';
|
||||||
|
|
||||||
const time = (minutes) =>
|
const time = (minutes) =>
|
||||||
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
|
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
|
||||||
@@ -37,7 +38,15 @@ function harness(t) {
|
|||||||
let at = Date.parse(time(20));
|
let at = Date.parse(time(20));
|
||||||
const timers = new Map();
|
const timers = new Map();
|
||||||
let id = 0;
|
let id = 0;
|
||||||
|
let mono = 0;
|
||||||
|
const world = createWorldClock({
|
||||||
|
now: () => at,
|
||||||
|
monotonicNow: () => mono,
|
||||||
|
setInterval: () => 0,
|
||||||
|
clearInterval() {},
|
||||||
|
});
|
||||||
const clock = createWeatherClock({
|
const clock = createWeatherClock({
|
||||||
|
world,
|
||||||
now: () => at,
|
now: () => at,
|
||||||
setTimeout(fn, delay) {
|
setTimeout(fn, delay) {
|
||||||
timers.set(++id, { fn, due: at + delay });
|
timers.set(++id, { fn, due: at + delay });
|
||||||
@@ -50,9 +59,11 @@ function harness(t) {
|
|||||||
t.after(() => clock.destroy());
|
t.after(() => clock.destroy());
|
||||||
return {
|
return {
|
||||||
clock,
|
clock,
|
||||||
|
world,
|
||||||
timers,
|
timers,
|
||||||
tick(ms) {
|
tick(ms) {
|
||||||
at += ms;
|
at += ms;
|
||||||
|
mono += ms;
|
||||||
for (const [id, timer] of [...timers]) {
|
for (const [id, timer] of [...timers]) {
|
||||||
if (timer.due > at) continue;
|
if (timer.due > at) continue;
|
||||||
timers.delete(id);
|
timers.delete(id);
|
||||||
@@ -275,3 +286,210 @@ test('registration joins history; unregister and destroy abort work and release
|
|||||||
assert.deepEqual(clock.getTimeline(), []);
|
assert.deepEqual(clock.getTimeline(), []);
|
||||||
assert.equal(await clock.setTarget(time(5)), false);
|
assert.equal(await clock.setTarget(time(5)), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the weather time is the world time, both ways', async (t) => {
|
||||||
|
const { clock, world } = harness(t);
|
||||||
|
const radar = product('radar', [0, 10, 20]);
|
||||||
|
clock.register(radar);
|
||||||
|
assert.equal(world.follows('radar'), true);
|
||||||
|
await clock.setTarget(time(10));
|
||||||
|
assert.equal(world.isLive(), false);
|
||||||
|
assert.equal(world.now(), Date.parse(time(10)));
|
||||||
|
world.seek(Date.parse(time(3)));
|
||||||
|
await flush();
|
||||||
|
assert.equal(clock.getState().target, time(3));
|
||||||
|
assert.equal(radar.shown, time(0));
|
||||||
|
world.goLive();
|
||||||
|
await flush();
|
||||||
|
assert.equal(clock.getState().mode, 'latest');
|
||||||
|
assert.equal(radar.shown, time(20));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('world play and pause drive the weather replay', async (t) => {
|
||||||
|
const { clock, world, tick } = harness(t);
|
||||||
|
const radar = product('radar', [0, 10, 20]);
|
||||||
|
clock.register(radar);
|
||||||
|
world.seek(Date.parse(time(0)));
|
||||||
|
await flush();
|
||||||
|
world.play();
|
||||||
|
assert.equal(clock.getState().playing, true);
|
||||||
|
assert.equal(world.isPlaying(), true);
|
||||||
|
tick(2000);
|
||||||
|
await flush();
|
||||||
|
assert.equal(radar.shown, time(10));
|
||||||
|
assert.equal(world.now(), Date.parse(time(10)));
|
||||||
|
world.pause();
|
||||||
|
assert.equal(clock.getState().playing, false);
|
||||||
|
clock.play();
|
||||||
|
assert.equal(world.isPlaying(), true);
|
||||||
|
clock.pause();
|
||||||
|
assert.equal(world.isPlaying(), false);
|
||||||
|
clock.play();
|
||||||
|
world.goLive();
|
||||||
|
await flush();
|
||||||
|
assert.equal(clock.getState().playing, false);
|
||||||
|
assert.equal(clock.getState().mode, 'latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a moving world re-applies frames only when a product changes frame', async (t) => {
|
||||||
|
const { clock, world } = harness(t);
|
||||||
|
const radar = product('radar', [0, 10, 20]);
|
||||||
|
clock.register(radar);
|
||||||
|
world.seek(Date.parse(time(0)));
|
||||||
|
await flush();
|
||||||
|
const applied = radar.calls.length;
|
||||||
|
world.seek(Date.parse(time(0)) + 60_000);
|
||||||
|
await flush();
|
||||||
|
assert.equal(radar.calls.length, applied);
|
||||||
|
assert.equal(
|
||||||
|
clock.getState().target,
|
||||||
|
new Date(Date.parse(time(1))).toISOString(),
|
||||||
|
);
|
||||||
|
world.seek(Date.parse(time(11)));
|
||||||
|
await flush();
|
||||||
|
assert.equal(radar.calls.length, applied + 1);
|
||||||
|
assert.equal(radar.shown, time(10));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a world without frames to step plays on its own', async (t) => {
|
||||||
|
const { clock, world, tick } = harness(t);
|
||||||
|
world.seek(Date.parse(time(0)));
|
||||||
|
world.play();
|
||||||
|
assert.equal(clock.getState().playing, false);
|
||||||
|
tick(1000);
|
||||||
|
assert.equal(world.now(), Date.parse(time(0)) + 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
function worldAt(at) {
|
||||||
|
return createWorldClock({
|
||||||
|
now: () => at,
|
||||||
|
monotonicNow: () => 0,
|
||||||
|
setInterval: () => 0,
|
||||||
|
clearInterval() {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('weather clocks sharing a world hand play over, and the last leaves it live', async () => {
|
||||||
|
const world = worldAt(Date.parse(time(20)));
|
||||||
|
const a = createWeatherClock({ world, now: () => Date.parse(time(20)) });
|
||||||
|
const b = createWeatherClock({ world, now: () => Date.parse(time(20)) });
|
||||||
|
const radarA = product('radar', [0, 10, 20]);
|
||||||
|
const radarB = product('radar', [0, 10, 20]);
|
||||||
|
a.register(radarA);
|
||||||
|
b.register(radarB);
|
||||||
|
await a.play();
|
||||||
|
assert.equal(world.isPlaying(), true);
|
||||||
|
world.pause();
|
||||||
|
assert.equal(a.getState().playing, false);
|
||||||
|
assert.equal(world.isPlaying(), false);
|
||||||
|
await a.play();
|
||||||
|
world.goLive();
|
||||||
|
assert.equal(a.getState().playing, false);
|
||||||
|
b.destroy();
|
||||||
|
assert.equal(world.follows('radar'), true);
|
||||||
|
world.seek(Date.parse(time(0)));
|
||||||
|
world.play();
|
||||||
|
assert.equal(a.getState().playing, true);
|
||||||
|
a.destroy();
|
||||||
|
assert.equal(world.follows('radar'), false);
|
||||||
|
assert.equal(world.isLive(), true);
|
||||||
|
assert.equal(world.isPlaying(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pausing from inside the first playing notice stops the world too', async (t) => {
|
||||||
|
const { clock, world } = harness(t);
|
||||||
|
clock.register(product('radar', [0, 10, 20]));
|
||||||
|
const off = clock.subscribe((state) => {
|
||||||
|
if (state.playing) {
|
||||||
|
off();
|
||||||
|
clock.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await clock.play();
|
||||||
|
assert.equal(clock.getState().playing, false);
|
||||||
|
assert.equal(world.isPlaying(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a step holds a world that was playing on its own', async (t) => {
|
||||||
|
const { clock, world } = harness(t);
|
||||||
|
world.seek(Date.parse(time(0)));
|
||||||
|
world.play();
|
||||||
|
clock.register(product('radar', [0, 10, 20]));
|
||||||
|
await clock.step(1);
|
||||||
|
assert.equal(world.isPlaying(), false);
|
||||||
|
assert.equal(world.now(), Date.parse(time(10)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a target superseded from a listener resolves false', async (t) => {
|
||||||
|
const { clock } = harness(t);
|
||||||
|
const radar = product('radar', [0, 10, 20]);
|
||||||
|
clock.register(radar);
|
||||||
|
await flush();
|
||||||
|
let redirected = false;
|
||||||
|
clock.subscribe((state) => {
|
||||||
|
if (state.target === time(0) && !redirected) {
|
||||||
|
redirected = true;
|
||||||
|
void clock.setTarget(time(10));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(await clock.setTarget(time(0)), false);
|
||||||
|
assert.equal(radar.shown, time(10));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each product’s frames are world clock coverage, and support the timeline while shown', (t) => {
|
||||||
|
const { clock, world } = harness(t);
|
||||||
|
assert.equal(world.coverage(), null);
|
||||||
|
assert.equal(world.getState().supported, false);
|
||||||
|
const radar = product('radar', [0, 10]);
|
||||||
|
const unregister = clock.register(radar);
|
||||||
|
clock.register(product('lightning', [5]));
|
||||||
|
assert.deepEqual(world.coverage(), {
|
||||||
|
start: Date.parse(time(0)),
|
||||||
|
end: Date.parse(time(10)),
|
||||||
|
marks: [0, 5, 10].map((m) => Date.parse(time(m))),
|
||||||
|
});
|
||||||
|
assert.equal(world.getState().supported, true);
|
||||||
|
unregister();
|
||||||
|
assert.deepEqual(world.coverage().marks, [Date.parse(time(5))]);
|
||||||
|
clock.destroy();
|
||||||
|
assert.equal(world.coverage(), null);
|
||||||
|
assert.equal(world.getState().supported, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a suspended product keeps its coverage, so the way back to live stays', (t) => {
|
||||||
|
const { clock, world } = harness(t);
|
||||||
|
const radar = product('radar', [0, 10]);
|
||||||
|
clock.register(radar);
|
||||||
|
radar.suspended = true;
|
||||||
|
void clock.refresh();
|
||||||
|
assert.equal(world.getState().supported, true);
|
||||||
|
assert.deepEqual(
|
||||||
|
world.coverage().marks,
|
||||||
|
[0, 10].map((m) => Date.parse(time(m))),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new frames reach the world clock through refresh', (t) => {
|
||||||
|
const { clock, world } = harness(t);
|
||||||
|
const radar = product('radar', []);
|
||||||
|
clock.register(radar);
|
||||||
|
const supported = [];
|
||||||
|
world.subscribe((state) => supported.push(state.supported));
|
||||||
|
assert.equal(world.getState().supported, false);
|
||||||
|
radar.times = [time(0)];
|
||||||
|
void clock.refresh();
|
||||||
|
assert.equal(supported.at(-1), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a clock destroyed while it registers a product lets go of it', (t) => {
|
||||||
|
const { clock, world } = harness(t);
|
||||||
|
const off = world.subscribe(() => {
|
||||||
|
off();
|
||||||
|
clock.destroy();
|
||||||
|
});
|
||||||
|
const unregister = clock.register(product('radar', [0, 10]));
|
||||||
|
unregister();
|
||||||
|
assert.equal(world.follows('radar'), false);
|
||||||
|
assert.equal(world.coverage(), null);
|
||||||
|
assert.equal(world.getState().supported, false);
|
||||||
|
});
|
||||||
@@ -52,7 +52,8 @@ const utc = (value) =>
|
|||||||
: t('Unavailable');
|
: t('Unavailable');
|
||||||
|
|
||||||
/** A per-application observation layer, using the existing layer lifecycle and
|
/** A per-application observation layer, using the existing layer lifecycle and
|
||||||
* row controls. History is transient: shared links always open latest imagery. */
|
* row controls. Its time is the world clock's, so a link made away from live
|
||||||
|
* opens its imagery at that time; one made live opens the latest. */
|
||||||
export function createWeatherLayer({
|
export function createWeatherLayer({
|
||||||
feed,
|
feed,
|
||||||
clock,
|
clock,
|
||||||
|
|||||||
+14
-2
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '../data/detectionPolicy.js';
|
} from '../data/detectionPolicy.js';
|
||||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||||
import { VIEW_STYLES } from '../view/index.js';
|
import { VIEW_STYLES } from '../view/index.js';
|
||||||
|
import { isWorldTime } from '../time/worldClock.js';
|
||||||
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
||||||
import {
|
import {
|
||||||
GLOBAL_POST_DEFAULTS,
|
GLOBAL_POST_DEFAULTS,
|
||||||
@@ -373,6 +374,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([
|
|||||||
{ number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 },
|
{ number: 25, key: 'scopeFeatherPct', type: 'uint', default: 100 },
|
||||||
{ number: 26, key: 'scopeTerminusPct', type: 'uint', default: null },
|
{ number: 26, key: 'scopeTerminusPct', type: 'uint', default: null },
|
||||||
{ number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE },
|
{ number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE },
|
||||||
|
{ number: 28, key: 'worldTime', type: 'epoch', default: null },
|
||||||
]);
|
]);
|
||||||
const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f]));
|
const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f]));
|
||||||
const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
|
const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
|
||||||
@@ -732,6 +734,9 @@ export function normalizeShareViewState(input = {}) {
|
|||||||
: null;
|
: null;
|
||||||
state.panelState = normalizePanelState(source.panelState);
|
state.panelState = normalizePanelState(source.panelState);
|
||||||
state.sharedAtMs = epochMsOr(source.sharedAtMs);
|
state.sharedAtMs = epochMsOr(source.sharedAtMs);
|
||||||
|
state.worldTime = isWorldTime(source.worldTime)
|
||||||
|
? Math.floor(source.worldTime / 1000) * 1000
|
||||||
|
: null;
|
||||||
let view = normalizeView(source.view);
|
let view = normalizeView(source.view);
|
||||||
if (state.layerState) view = liftTrackedEntity(state.layerState, view);
|
if (state.layerState) view = liftTrackedEntity(state.layerState, view);
|
||||||
if (view.kind === 'thing') {
|
if (view.kind === 'thing') {
|
||||||
@@ -948,8 +953,9 @@ export function encodeShareView(input) {
|
|||||||
writer.sintField(field.number, signedAngle(state[field.key]));
|
writer.sintField(field.number, signedAngle(state[field.key]));
|
||||||
break;
|
break;
|
||||||
case 'sharedAtMs':
|
case 'sharedAtMs':
|
||||||
if (state.sharedAtMs !== null)
|
case 'worldTime':
|
||||||
writer.sintField(field.number, epochSeconds(state.sharedAtMs));
|
if (state[field.key] !== null)
|
||||||
|
writer.sintField(field.number, epochSeconds(state[field.key]));
|
||||||
break;
|
break;
|
||||||
case 'flags': {
|
case 'flags': {
|
||||||
let bits = 0;
|
let bits = 0;
|
||||||
@@ -1347,6 +1353,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
|
|||||||
raw.sharedAtMs =
|
raw.sharedAtMs =
|
||||||
createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null;
|
createdAtS >= 1 && Number.isSafeInteger(ms) && ms <= nowMs ? ms : null;
|
||||||
}
|
}
|
||||||
|
// The world time the link was made at, when it was away from live. It
|
||||||
|
// may lie ahead of the link's creation (a forecast) or of now.
|
||||||
|
if (get('worldTime')) {
|
||||||
|
const ms = (SHARE_VIEW_EPOCH_S + unz(get('worldTime').varint)) * 1000;
|
||||||
|
raw.worldTime = isWorldTime(ms) ? ms : null;
|
||||||
|
}
|
||||||
|
|
||||||
if (get('layerState'))
|
if (get('layerState'))
|
||||||
raw.layerState = decodeLayers(
|
raw.layerState = decodeLayers(
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ test('every registered field round-trips at once', () => {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
sharedAtMs: NOW_MS,
|
sharedAtMs: NOW_MS,
|
||||||
|
worldTime: NOW_MS - 6 * 3_600_000,
|
||||||
};
|
};
|
||||||
// A thing view derives its camera (roll 0), so the camera fields are
|
// A thing view derives its camera (roll 0), so the camera fields are
|
||||||
// checked on the same state as a place view.
|
// checked on the same state as a place view.
|
||||||
@@ -501,6 +502,7 @@ test('the field registry is well formed and permanent', () => {
|
|||||||
[25, 'scopeFeatherPct', 100],
|
[25, 'scopeFeatherPct', 100],
|
||||||
[26, 'scopeTerminusPct', null],
|
[26, 'scopeTerminusPct', null],
|
||||||
[27, 'styleParams', BY_STYLE],
|
[27, 'styleParams', BY_STYLE],
|
||||||
|
[28, 'worldTime', null],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -1338,3 +1340,22 @@ test('a layer option filed under a mirror layer is ignored', () => {
|
|||||||
{ scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` },
|
{ scope: 'layer-option', code: `${military}.${layerTokenNumber('m')}` },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the world time is written only away from live, and may lie ahead', () => {
|
||||||
|
const at = (worldTime) =>
|
||||||
|
decodeShareView(encodeShareView({ sharedAtMs: NOW_MS, worldTime }), {
|
||||||
|
nowMs: NOW_MS,
|
||||||
|
}).state.worldTime;
|
||||||
|
assert.equal(at(null), null);
|
||||||
|
assert.equal(at(NOW_MS - 3_600_000), NOW_MS - 3_600_000);
|
||||||
|
// A forecast moment ahead of the link's creation is kept.
|
||||||
|
assert.equal(at(NOW_MS + 86_400_000), NOW_MS + 86_400_000);
|
||||||
|
// Before the 2026 epoch too: the field is zigzag.
|
||||||
|
assert.equal(at(Date.UTC(2024, 0, 1)), Date.UTC(2024, 0, 1));
|
||||||
|
// Past what a Date can hold: dropped, so the link opens live.
|
||||||
|
assert.equal(at(8.8e15), null);
|
||||||
|
assert.ok(
|
||||||
|
encodeShareView({ sharedAtMs: NOW_MS, worldTime: NOW_MS - 1000 }).length >
|
||||||
|
encodeShareView({ sharedAtMs: NOW_MS }).length,
|
||||||
|
);
|
||||||
|
});
|
||||||
+11
-2
@@ -5,6 +5,7 @@ import {
|
|||||||
normalizeAllocationStrategy,
|
normalizeAllocationStrategy,
|
||||||
} from './data/detectionPolicy.js';
|
} from './data/detectionPolicy.js';
|
||||||
import { clampScopeTerminusPct } from './scopeMask.js';
|
import { clampScopeTerminusPct } from './scopeMask.js';
|
||||||
|
import { worldClock as pageWorldClock } from './time/worldClock.js';
|
||||||
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
|
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
|
||||||
import {
|
import {
|
||||||
decodeLayerStateParams,
|
decodeLayerStateParams,
|
||||||
@@ -68,9 +69,11 @@ export class ShareLinkManager {
|
|||||||
isNavigationCurrent,
|
isNavigationCurrent,
|
||||||
cancelOwnedNavigation,
|
cancelOwnedNavigation,
|
||||||
initialShareCode = null,
|
initialShareCode = null,
|
||||||
|
worldClock = pageWorldClock,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
this.viewer = viewer;
|
this.viewer = viewer;
|
||||||
|
this._worldClock = worldClock;
|
||||||
// A host may hand over the share code to open instead of the address.
|
// A host may hand over the share code to open instead of the address.
|
||||||
this._initialShareCode =
|
this._initialShareCode =
|
||||||
typeof initialShareCode === 'string' ? initialShareCode : null;
|
typeof initialShareCode === 'string' ? initialShareCode : null;
|
||||||
@@ -163,8 +166,9 @@ export class ShareLinkManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The restore state for a share code, in the long form's parsed shape plus
|
* The restore state for a share code, in the long form's parsed shape plus
|
||||||
* `shareView` (what the view follows, with its offset and last-known fix),
|
* `shareView` (what the view follows, with its offset and last-known fix)
|
||||||
* or null when the code is refused.
|
* and `worldTime` (null when the link was made live), or null when the
|
||||||
|
* code is refused.
|
||||||
*/
|
*/
|
||||||
_parseShareCode(code) {
|
_parseShareCode(code) {
|
||||||
const result = decodeShareView(code);
|
const result = decodeShareView(code);
|
||||||
@@ -174,6 +178,7 @@ export class ShareLinkManager {
|
|||||||
}
|
}
|
||||||
const state = toLegacyShareState(result.state);
|
const state = toLegacyShareState(result.state);
|
||||||
state.shareView = result.state.view;
|
state.shareView = result.state.view;
|
||||||
|
state.worldTime = result.state.worldTime;
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,6 +597,10 @@ export class ShareLinkManager {
|
|||||||
// that state in the share format.
|
// that state in the share format.
|
||||||
const state = fromLegacyShareState(this._parseShareHash(params.toString()));
|
const state = fromLegacyShareState(this._parseShareHash(params.toString()));
|
||||||
state.sharedAtMs = Math.floor(nowMs / 1000) * 1000;
|
state.sharedAtMs = Math.floor(nowMs / 1000) * 1000;
|
||||||
|
// Away from live, the link opens at the same world time.
|
||||||
|
state.worldTime = this._worldClock.isLive()
|
||||||
|
? null
|
||||||
|
: Math.floor(this._worldClock.now() / 1000) * 1000;
|
||||||
let subject = null;
|
let subject = null;
|
||||||
try {
|
try {
|
||||||
subject = this._followSubjectProvider?.({ nowMs }) ?? null;
|
subject = this._followSubjectProvider?.({ nowMs }) ?? null;
|
||||||
|
|||||||
@@ -423,3 +423,28 @@ test('the layer coordinator replaces in-flight share layers with the navigated s
|
|||||||
);
|
);
|
||||||
coordinator.destroy();
|
coordinator.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a link made away from live carries its world time to the page that opens it', (t) => {
|
||||||
|
installTab(t, SHARE_A);
|
||||||
|
const worldTime = Date.UTC(2026, 9, 30, 6, 15, 42);
|
||||||
|
const sharer = new ShareLinkManager(fakeViewer(), {
|
||||||
|
worldClock: { isLive: () => false, now: () => worldTime + 400 },
|
||||||
|
});
|
||||||
|
const link = sharer.buildLink({ nowMs: Date.UTC(2026, 10, 1) });
|
||||||
|
sharer.destroy();
|
||||||
|
const code = new URL(link).pathname.split('/').at(-1);
|
||||||
|
const opener = new ShareLinkManager(fakeViewer(), { initialShareCode: code });
|
||||||
|
assert.equal(opener.parseInitialHash().worldTime, worldTime);
|
||||||
|
opener.destroy();
|
||||||
|
|
||||||
|
const live = new ShareLinkManager(fakeViewer(), {
|
||||||
|
worldClock: { isLive: () => true, now: () => worldTime },
|
||||||
|
});
|
||||||
|
const liveCode = new URL(live.buildLink()).pathname.split('/').at(-1);
|
||||||
|
live.destroy();
|
||||||
|
const liveOpener = new ShareLinkManager(fakeViewer(), {
|
||||||
|
initialShareCode: liveCode,
|
||||||
|
});
|
||||||
|
assert.equal(liveOpener.parseInitialHash().worldTime, null);
|
||||||
|
liveOpener.destroy();
|
||||||
|
});
|
||||||
@@ -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,349 @@
|
|||||||
|
/**
|
||||||
|
* The world's one time, in epoch ms. Every layer that draws something at a
|
||||||
|
* moment reads it here instead of calling `Date.now()` itself, so layers can
|
||||||
|
* never disagree about when the world is.
|
||||||
|
*
|
||||||
|
* Live is a mode, not a timestamp: while live the clock is the wall clock and
|
||||||
|
* every layer behaves as it would reading `Date.now()`. Away from live the
|
||||||
|
* clock holds a cursor, fixed or playing. Only the layers that follow the clock
|
||||||
|
* move with the cursor; the rest keep reading the live edge through
|
||||||
|
* `timeFor(id)`. On the page's clock the layers whose data has a time of its
|
||||||
|
* own (the sky, satellites, launches and weather) follow it; live feeds such
|
||||||
|
* as flights and vessels have no time to move to and stay live.
|
||||||
|
*
|
||||||
|
* Playback is continuous (`cursor + elapsed × rate`) unless a driver takes
|
||||||
|
* it: weather replay steps its observed frames itself and moves the clock as
|
||||||
|
* it goes, so play and pause hand over to it. Several drivers may be attached;
|
||||||
|
* play goes to the newest that can play, pause to the one playing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The span a JS Date can hold, either side of 1970. */
|
||||||
|
const MAX_DATE_MS = 8.64e15;
|
||||||
|
|
||||||
|
/** Whether `ms` is a time the world clock can hold. */
|
||||||
|
export const isWorldTime = (ms) =>
|
||||||
|
Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS;
|
||||||
|
|
||||||
|
/** How often subscribers hear of a continuously playing cursor. */
|
||||||
|
export const WORLD_CLOCK_TICK_MS = 250;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{now?: () => number, monotonicNow?: () => number,
|
||||||
|
* setInterval?: typeof globalThis.setInterval,
|
||||||
|
* clearInterval?: typeof globalThis.clearInterval,
|
||||||
|
* follow?: Iterable<string>}} [options] `follow` lists the layers that
|
||||||
|
* move with the cursor from the start.
|
||||||
|
*/
|
||||||
|
export function createWorldClock({
|
||||||
|
// Read at each call, so a stand-in for Date.now (a test's, a harness's)
|
||||||
|
// reaches every layer as it did when they called Date.now themselves.
|
||||||
|
now = () => Date.now(),
|
||||||
|
monotonicNow = () => globalThis.performance.now(),
|
||||||
|
setInterval = globalThis.setInterval,
|
||||||
|
clearInterval = globalThis.clearInterval,
|
||||||
|
follow = [],
|
||||||
|
} = {}) {
|
||||||
|
const listeners = new Set();
|
||||||
|
// Layers a host follows, and layers a part of the app holds following.
|
||||||
|
const following = new Set(follow);
|
||||||
|
const holds = new Map();
|
||||||
|
const readers = new Map();
|
||||||
|
const sources = new Set();
|
||||||
|
const drivers = [];
|
||||||
|
let live = true;
|
||||||
|
// The cursor while fixed; while playing continuously, the cursor at `anchorMono`.
|
||||||
|
let cursorMs = null;
|
||||||
|
let anchorMono = 0;
|
||||||
|
let rate = 1;
|
||||||
|
let playing = false;
|
||||||
|
// The driver handle stepping the clock, or null.
|
||||||
|
let active = null;
|
||||||
|
let ticker = null;
|
||||||
|
let notifying = 0;
|
||||||
|
|
||||||
|
const continuous = () => playing && !active;
|
||||||
|
const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
|
||||||
|
const followedIds = () => [
|
||||||
|
...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
|
||||||
|
];
|
||||||
|
// A host's coverage that throws counts as none, so it cannot break the
|
||||||
|
// listeners every state reaches.
|
||||||
|
const spanOf = (source) => {
|
||||||
|
let span;
|
||||||
|
try {
|
||||||
|
span = source.coverage();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`World time coverage failed: ${source.id}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return span && span.end >= span.start ? span : null;
|
||||||
|
};
|
||||||
|
// Whether a layer that moves with the clock has time to show: only then is
|
||||||
|
// there anything for a timeline to play.
|
||||||
|
const isSupported = () => {
|
||||||
|
for (const { source } of sources)
|
||||||
|
if (isFollowed(source.id) && spanOf(source)) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const time = () => {
|
||||||
|
if (live) return now();
|
||||||
|
if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate;
|
||||||
|
return cursorMs;
|
||||||
|
};
|
||||||
|
const getState = () =>
|
||||||
|
Object.freeze({
|
||||||
|
live,
|
||||||
|
timeMs: time(),
|
||||||
|
playing,
|
||||||
|
rate,
|
||||||
|
following: followedIds(),
|
||||||
|
supported: isSupported(),
|
||||||
|
});
|
||||||
|
// Each listener hears the state as it is when called. A change made from
|
||||||
|
// inside a listener notifies everyone afresh, and the outer round stops, so
|
||||||
|
// nobody is left holding a state that has since changed.
|
||||||
|
// A round's state is read once: any change made during it starts a new one.
|
||||||
|
const notify = () => {
|
||||||
|
const round = ++notifying;
|
||||||
|
let state = null;
|
||||||
|
for (const listener of [...listeners]) {
|
||||||
|
if (round !== notifying) return;
|
||||||
|
if (!listeners.has(listener)) continue;
|
||||||
|
try {
|
||||||
|
listener((state ??= getState()));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('World clock listener failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const syncTicker = () => {
|
||||||
|
if (continuous() && listeners.size) {
|
||||||
|
ticker ??= setInterval(notify, WORLD_CLOCK_TICK_MS);
|
||||||
|
} else if (ticker !== null) {
|
||||||
|
clearInterval(ticker);
|
||||||
|
ticker = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const changed = () => {
|
||||||
|
syncTicker();
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
// Freeze a continuously playing cursor where it is.
|
||||||
|
const settle = () => {
|
||||||
|
if (continuous()) cursorMs = time();
|
||||||
|
anchorMono = monotonicNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
/** The live edge: the wall clock, whatever the cursor does. */
|
||||||
|
wallNow: () => now(),
|
||||||
|
/** The world time: the wall clock while live, else the cursor. */
|
||||||
|
now: time,
|
||||||
|
isLive: () => live,
|
||||||
|
isPlaying: () => playing,
|
||||||
|
getRate: () => rate,
|
||||||
|
|
||||||
|
/** Hold the world at `ms`; a playing clock plays on from there. */
|
||||||
|
seek(ms) {
|
||||||
|
if (!isWorldTime(ms)) throw new TypeError('World time must be epoch ms');
|
||||||
|
if (!live && cursorMs === ms && !continuous()) return;
|
||||||
|
live = false;
|
||||||
|
cursorMs = ms;
|
||||||
|
anchorMono = monotonicNow();
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
/** Back to the wall clock; playback stops. */
|
||||||
|
goLive() {
|
||||||
|
if (live && !playing) return;
|
||||||
|
const stepping = active;
|
||||||
|
live = true;
|
||||||
|
cursorMs = null;
|
||||||
|
playing = false;
|
||||||
|
active = null;
|
||||||
|
// Its driven(false) finds nothing left to stop.
|
||||||
|
stepping?.driver.pause();
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
/** Play from the cursor (from now, when live). */
|
||||||
|
play({ rate: nextRate } = {}) {
|
||||||
|
if (nextRate !== undefined) api.setRate(nextRate);
|
||||||
|
if (playing) return;
|
||||||
|
for (let index = drivers.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (drivers[index].driver.play() !== false) return;
|
||||||
|
}
|
||||||
|
if (live) {
|
||||||
|
live = false;
|
||||||
|
cursorMs = now();
|
||||||
|
}
|
||||||
|
playing = true;
|
||||||
|
anchorMono = monotonicNow();
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
if (!playing) return;
|
||||||
|
if (active) {
|
||||||
|
active.driver.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settle();
|
||||||
|
playing = false;
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
togglePlay() {
|
||||||
|
return playing ? api.pause() : api.play();
|
||||||
|
},
|
||||||
|
/** Simulated ms per real ms for continuous playback. */
|
||||||
|
setRate(nextRate) {
|
||||||
|
if (!Number.isFinite(nextRate) || nextRate <= 0)
|
||||||
|
throw new RangeError('World clock rate must be positive');
|
||||||
|
if (nextRate === rate) return;
|
||||||
|
settle();
|
||||||
|
rate = nextRate;
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer a stepping player. The driver moves the clock with `seek()` and
|
||||||
|
* reports through its handle's `driven(playing, atMs?)`; its `play()`
|
||||||
|
* returns false when it cannot play, and the clock tries an older driver
|
||||||
|
* or plays continuously instead.
|
||||||
|
* @param {{play: () => (boolean|void), pause: () => void}} driver
|
||||||
|
* @returns {{driven: (playing: boolean, atMs?: number) => void,
|
||||||
|
* detach: () => number}} `detach()` returns how many drivers remain.
|
||||||
|
*/
|
||||||
|
attachDriver(driver) {
|
||||||
|
const handle = {
|
||||||
|
driver,
|
||||||
|
/** This driver started stepping (at `atMs`, if given) or stopped. */
|
||||||
|
driven(isPlaying, atMs) {
|
||||||
|
if (!drivers.includes(handle)) return;
|
||||||
|
if (!isPlaying) {
|
||||||
|
if (active !== handle) return;
|
||||||
|
active = null;
|
||||||
|
playing = false;
|
||||||
|
changed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A continuous run hands over where it is; another driver stops.
|
||||||
|
settle();
|
||||||
|
const previous = active;
|
||||||
|
active = handle;
|
||||||
|
playing = true;
|
||||||
|
live = false;
|
||||||
|
if (Number.isFinite(atMs)) cursorMs = atMs;
|
||||||
|
else cursorMs ??= now();
|
||||||
|
anchorMono = monotonicNow();
|
||||||
|
if (previous && previous !== handle) previous.driver.pause();
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
detach() {
|
||||||
|
const index = drivers.indexOf(handle);
|
||||||
|
if (index < 0) return drivers.length;
|
||||||
|
handle.driven(false);
|
||||||
|
drivers.splice(index, 1);
|
||||||
|
return drivers.length;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
drivers.push(handle);
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Let layer `id` move with the cursor instead of reading the live edge. */
|
||||||
|
follow(id, on = true) {
|
||||||
|
if (on === following.has(id)) return;
|
||||||
|
if (on) following.add(id);
|
||||||
|
else following.delete(id);
|
||||||
|
changed();
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Keep layer `id` following until the returned release is called, however
|
||||||
|
* many parts of the app hold it.
|
||||||
|
*/
|
||||||
|
hold(id) {
|
||||||
|
const was = isFollowed(id);
|
||||||
|
holds.set(id, (holds.get(id) ?? 0) + 1);
|
||||||
|
if (!was) changed();
|
||||||
|
let held = true;
|
||||||
|
return () => {
|
||||||
|
if (!held) return;
|
||||||
|
held = false;
|
||||||
|
const count = holds.get(id) - 1;
|
||||||
|
if (count > 0) holds.set(id, count);
|
||||||
|
else holds.delete(id);
|
||||||
|
if (!isFollowed(id)) changed();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
follows: isFollowed,
|
||||||
|
/**
|
||||||
|
* The time layer `id` draws at: the cursor when it follows the clock,
|
||||||
|
* otherwise the live edge.
|
||||||
|
* @returns {() => number}
|
||||||
|
*/
|
||||||
|
timeFor(id) {
|
||||||
|
let reader = readers.get(id);
|
||||||
|
if (!reader) {
|
||||||
|
reader = () => (isFollowed(id) ? time() : now());
|
||||||
|
readers.set(id, reader);
|
||||||
|
}
|
||||||
|
return reader;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A layer's span of time it can show, for a host's timeline; null while
|
||||||
|
* it has none (off, or nothing loaded). Returns the unregister.
|
||||||
|
* @param {{id: string, coverage: () => ({start: number, end: number, marks?: number[]} | null)}} source
|
||||||
|
*/
|
||||||
|
register(source) {
|
||||||
|
// Each registration is its own, even under a shared id.
|
||||||
|
const entry = { source };
|
||||||
|
sources.add(entry);
|
||||||
|
changed();
|
||||||
|
return () => {
|
||||||
|
if (sources.delete(entry)) changed();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/** A source's coverage changed (frames loaded, the layer paused). */
|
||||||
|
coverageChanged: () => changed(),
|
||||||
|
/** Whether a followed layer has time to show; see getState().supported. */
|
||||||
|
isSupported,
|
||||||
|
/** The union of the registered sources' coverage, or null with none. */
|
||||||
|
coverage() {
|
||||||
|
let start = Infinity;
|
||||||
|
let end = -Infinity;
|
||||||
|
const marks = new Set();
|
||||||
|
for (const { source } of sources) {
|
||||||
|
const span = spanOf(source);
|
||||||
|
if (!span) continue;
|
||||||
|
start = Math.min(start, span.start);
|
||||||
|
end = Math.max(end, span.end);
|
||||||
|
for (const mark of span.marks ?? []) marks.add(mark);
|
||||||
|
}
|
||||||
|
if (start > end) return null;
|
||||||
|
return { start, end, marks: [...marks].sort((a, b) => a - b) };
|
||||||
|
},
|
||||||
|
|
||||||
|
getState,
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
syncTicker();
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
syncTicker();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page's world clock. Layers, the sky and a host all read this one. The
|
||||||
|
* sky and the HUD's clock show the world time; satellites (and the launched
|
||||||
|
* missions flying on their time) and launches have a time of their own and
|
||||||
|
* move with it; weather holds each product it shows.
|
||||||
|
*/
|
||||||
|
export const worldClock = createWorldClock({
|
||||||
|
follow: ['sky', 'hud', 'satellites', 'rocket-launches'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The time layer `id` draws at on the page's clock. */
|
||||||
|
export const worldTimeFor = (id) => worldClock.timeFor(id);
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createWorldClock, WORLD_CLOCK_TICK_MS } from './worldClock.js';
|
||||||
|
|
||||||
|
function fakeTime() {
|
||||||
|
const t = {
|
||||||
|
wall: Date.UTC(2026, 10, 1, 12),
|
||||||
|
mono: 1_000,
|
||||||
|
timers: new Map(),
|
||||||
|
nextId: 1,
|
||||||
|
advance(ms) {
|
||||||
|
t.wall += ms;
|
||||||
|
t.mono += ms;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
now: () => t.wall,
|
||||||
|
monotonicNow: () => t.mono,
|
||||||
|
setInterval: (fn, ms) => {
|
||||||
|
const id = t.nextId++;
|
||||||
|
t.timers.set(id, { fn, ms });
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearInterval: (id) => t.timers.delete(id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('live is the wall clock for every layer', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
assert.equal(clock.isLive(), true);
|
||||||
|
assert.equal(clock.now(), t.wall);
|
||||||
|
t.advance(5_000);
|
||||||
|
assert.equal(clock.now(), t.wall);
|
||||||
|
assert.equal(clock.timeFor('flights')(), t.wall);
|
||||||
|
clock.follow('flights');
|
||||||
|
assert.equal(clock.timeFor('flights')(), t.wall);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('away from live only following layers move with the cursor', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
const satellites = clock.timeFor('satellites');
|
||||||
|
const flights = clock.timeFor('flights');
|
||||||
|
clock.follow('satellites');
|
||||||
|
const past = t.wall - 3_600_000;
|
||||||
|
clock.seek(past);
|
||||||
|
assert.equal(clock.isLive(), false);
|
||||||
|
assert.equal(clock.now(), past);
|
||||||
|
assert.equal(satellites(), past);
|
||||||
|
assert.equal(flights(), t.wall);
|
||||||
|
assert.equal(clock.timeFor('satellites'), satellites);
|
||||||
|
clock.goLive();
|
||||||
|
assert.equal(satellites(), t.wall);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('layers named at creation follow from the start', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock({ ...t.options, follow: ['satellites'] });
|
||||||
|
clock.seek(t.wall - 1_000);
|
||||||
|
assert.equal(clock.timeFor('satellites')(), t.wall - 1_000);
|
||||||
|
assert.equal(clock.timeFor('flights')(), t.wall);
|
||||||
|
clock.follow('satellites', false);
|
||||||
|
assert.equal(clock.follows('satellites'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the page clock moves the layers with a time of their own', async () => {
|
||||||
|
const { worldClock } = await import('./worldClock.js');
|
||||||
|
for (const id of ['sky', 'hud', 'satellites', 'rocket-launches'])
|
||||||
|
assert.equal(worldClock.follows(id), true, id);
|
||||||
|
for (const id of ['flights', 'military', 'vessels', 'transit'])
|
||||||
|
assert.equal(worldClock.follows(id), false, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('continuous play advances the cursor at the rate and pause holds it', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
const start = t.wall - 60_000;
|
||||||
|
clock.seek(start);
|
||||||
|
clock.play({ rate: 60 });
|
||||||
|
t.advance(1_000);
|
||||||
|
assert.equal(clock.now(), start + 60_000);
|
||||||
|
clock.setRate(120);
|
||||||
|
t.advance(1_000);
|
||||||
|
assert.equal(clock.now(), start + 180_000);
|
||||||
|
clock.pause();
|
||||||
|
t.advance(1_000);
|
||||||
|
assert.equal(clock.now(), start + 180_000);
|
||||||
|
assert.equal(clock.isPlaying(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('play from live starts at now, and goLive returns to the wall clock', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
const from = t.wall;
|
||||||
|
clock.play({ rate: 2 });
|
||||||
|
assert.equal(clock.isLive(), false);
|
||||||
|
t.advance(1_000);
|
||||||
|
assert.equal(clock.now(), from + 2_000);
|
||||||
|
clock.goLive();
|
||||||
|
assert.equal(clock.isPlaying(), false);
|
||||||
|
assert.equal(clock.now(), t.wall);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscribers hear changes, and ticks only while playing continuously', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
const states = [];
|
||||||
|
const off = clock.subscribe((state) => states.push(state));
|
||||||
|
clock.seek(t.wall - 1_000);
|
||||||
|
assert.equal(states.length, 1);
|
||||||
|
assert.equal(states[0].live, false);
|
||||||
|
assert.equal(t.timers.size, 0);
|
||||||
|
clock.play();
|
||||||
|
assert.equal(t.timers.size, 1);
|
||||||
|
assert.equal([...t.timers.values()][0].ms, WORLD_CLOCK_TICK_MS);
|
||||||
|
clock.pause();
|
||||||
|
assert.equal(t.timers.size, 0);
|
||||||
|
clock.play();
|
||||||
|
off();
|
||||||
|
assert.equal(t.timers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a driver takes play and pause, and moves the clock with seek', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
const calls = [];
|
||||||
|
const handle = clock.attachDriver({
|
||||||
|
play() {
|
||||||
|
calls.push('play');
|
||||||
|
handle.driven(true, t.wall - 10_000);
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
calls.push('pause');
|
||||||
|
handle.driven(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clock.play();
|
||||||
|
assert.deepEqual(calls, ['play']);
|
||||||
|
assert.equal(clock.isPlaying(), true);
|
||||||
|
assert.equal(clock.now(), t.wall - 10_000);
|
||||||
|
const at = clock.now();
|
||||||
|
t.advance(5_000);
|
||||||
|
// Stepped, not continuous: the cursor waits for the driver.
|
||||||
|
assert.equal(clock.now(), at);
|
||||||
|
clock.pause();
|
||||||
|
assert.deepEqual(calls, ['play', 'pause']);
|
||||||
|
assert.equal(clock.isPlaying(), false);
|
||||||
|
clock.play();
|
||||||
|
clock.goLive();
|
||||||
|
assert.deepEqual(calls, ['play', 'pause', 'play', 'pause']);
|
||||||
|
assert.equal(clock.isLive(), true);
|
||||||
|
assert.equal(clock.isPlaying(), false);
|
||||||
|
assert.equal(handle.detach(), 0);
|
||||||
|
clock.play();
|
||||||
|
assert.equal(calls.length, 4);
|
||||||
|
t.advance(1_000);
|
||||||
|
assert.equal(clock.now(), t.wall);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('play goes to the newest driver that can play, pause to the one playing', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
const calls = [];
|
||||||
|
const driver = (name, canPlay) => {
|
||||||
|
const handle = clock.attachDriver({
|
||||||
|
play() {
|
||||||
|
if (!canPlay()) return false;
|
||||||
|
calls.push(`${name} play`);
|
||||||
|
handle.driven(true);
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
calls.push(`${name} pause`);
|
||||||
|
handle.driven(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return handle;
|
||||||
|
};
|
||||||
|
let bCanPlay = false;
|
||||||
|
const a = driver('a', () => true);
|
||||||
|
const b = driver('b', () => bCanPlay);
|
||||||
|
clock.seek(t.wall - 1_000);
|
||||||
|
clock.play();
|
||||||
|
assert.deepEqual(calls, ['a play']);
|
||||||
|
// b starting takes over from a.
|
||||||
|
bCanPlay = true;
|
||||||
|
b.driven(true);
|
||||||
|
assert.deepEqual(calls, ['a play', 'a pause']);
|
||||||
|
clock.pause();
|
||||||
|
assert.deepEqual(calls, ['a play', 'a pause', 'b pause']);
|
||||||
|
assert.equal(clock.isPlaying(), false);
|
||||||
|
// A detached driver stops, and the other one plays next.
|
||||||
|
clock.play();
|
||||||
|
assert.equal(b.detach(), 1);
|
||||||
|
assert.equal(clock.isPlaying(), false);
|
||||||
|
clock.play();
|
||||||
|
assert.equal(calls.at(-1), 'a play');
|
||||||
|
assert.equal(a.detach(), 0);
|
||||||
|
assert.equal(clock.isPlaying(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a listener that changes the clock leaves nobody with a stale state', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
const seen = [];
|
||||||
|
clock.subscribe((state) => {
|
||||||
|
if (state.timeMs === 0) clock.seek(1_000);
|
||||||
|
});
|
||||||
|
clock.subscribe((state) => seen.push(state.timeMs));
|
||||||
|
clock.seek(0);
|
||||||
|
assert.deepEqual(seen, [1_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holds keep a layer following until every holder releases it', () => {
|
||||||
|
const clock = createWorldClock(fakeTime().options);
|
||||||
|
const first = clock.hold('radar');
|
||||||
|
const second = clock.hold('radar');
|
||||||
|
first();
|
||||||
|
first();
|
||||||
|
assert.equal(clock.follows('radar'), true);
|
||||||
|
second();
|
||||||
|
assert.equal(clock.follows('radar'), false);
|
||||||
|
clock.follow('radar');
|
||||||
|
const third = clock.hold('radar');
|
||||||
|
third();
|
||||||
|
assert.equal(clock.follows('radar'), true);
|
||||||
|
assert.deepEqual(clock.getState().following, ['radar']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a driver that cannot play leaves continuous playback', () => {
|
||||||
|
const t = fakeTime();
|
||||||
|
const clock = createWorldClock(t.options);
|
||||||
|
clock.attachDriver({ play: () => false, pause() {} });
|
||||||
|
clock.seek(t.wall - 10_000);
|
||||||
|
clock.play();
|
||||||
|
assert.equal(clock.isPlaying(), true);
|
||||||
|
t.advance(1_000);
|
||||||
|
// Wall and cursor moved the same second at rate 1.
|
||||||
|
assert.equal(clock.now(), t.wall - 10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverage is the union of the registered sources', () => {
|
||||||
|
const clock = createWorldClock(fakeTime().options);
|
||||||
|
assert.equal(clock.coverage(), null);
|
||||||
|
const off = clock.register({
|
||||||
|
id: 'radar',
|
||||||
|
coverage: () => ({ start: 100, end: 200, marks: [100, 150, 200] }),
|
||||||
|
});
|
||||||
|
clock.register({ id: 'satellites', coverage: () => ({ start: 50, end: 180 }) });
|
||||||
|
clock.register({ id: 'empty', coverage: () => null });
|
||||||
|
assert.deepEqual(clock.coverage(), {
|
||||||
|
start: 50,
|
||||||
|
end: 200,
|
||||||
|
marks: [100, 150, 200],
|
||||||
|
});
|
||||||
|
off();
|
||||||
|
assert.deepEqual(clock.coverage(), { start: 50, end: 180, marks: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seek refuses anything but epoch ms and rate must be positive', () => {
|
||||||
|
const clock = createWorldClock(fakeTime().options);
|
||||||
|
assert.throws(() => clock.seek(Number.NaN), TypeError);
|
||||||
|
assert.throws(() => clock.setRate(0), RangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seek refuses a time no Date can hold', () => {
|
||||||
|
const clock = createWorldClock(fakeTime().options);
|
||||||
|
assert.throws(() => clock.seek(8.8e15), TypeError);
|
||||||
|
clock.seek(-8.64e15);
|
||||||
|
assert.equal(clock.now(), -8.64e15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only a followed layer with time to show supports the timeline', () => {
|
||||||
|
const clock = createWorldClock(fakeTime().options);
|
||||||
|
let span = null;
|
||||||
|
const states = [];
|
||||||
|
clock.subscribe((state) => states.push(state.supported));
|
||||||
|
const off = clock.register({ id: 'satellites', coverage: () => span });
|
||||||
|
span = { start: 0, end: 10 };
|
||||||
|
clock.coverageChanged();
|
||||||
|
assert.equal(clock.isSupported(), false, 'covered but not followed');
|
||||||
|
clock.follow('satellites');
|
||||||
|
assert.equal(clock.isSupported(), true);
|
||||||
|
assert.equal(states.at(-1), true);
|
||||||
|
span = null;
|
||||||
|
clock.coverageChanged();
|
||||||
|
assert.equal(states.at(-1), false);
|
||||||
|
span = { start: 0, end: 10 };
|
||||||
|
off();
|
||||||
|
assert.equal(clock.isSupported(), false);
|
||||||
|
assert.equal(states.at(-1), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a coverage that throws counts as none', (t) => {
|
||||||
|
t.mock.method(console, 'error', () => {});
|
||||||
|
const clock = createWorldClock(fakeTime().options);
|
||||||
|
clock.follow('broken');
|
||||||
|
clock.register({
|
||||||
|
id: 'broken',
|
||||||
|
coverage() {
|
||||||
|
throw new Error('no');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(clock.getState().supported, false);
|
||||||
|
assert.equal(clock.coverage(), null);
|
||||||
|
});
|
||||||
@@ -434,12 +434,12 @@ test('Cyber lower-left telemetry tracks the credit anchor with clearance for its
|
|||||||
cyberStyles,
|
cyberStyles,
|
||||||
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/,
|
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/,
|
||||||
);
|
);
|
||||||
const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/);
|
const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/);
|
||||||
assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
|
assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
|
||||||
const credits = read('./styles/command-dock-trays.css');
|
const credits = read('./styles/command-dock-trays.css');
|
||||||
const creditOffsets = [
|
const creditOffsets = [
|
||||||
...credits.matchAll(
|
...credits.matchAll(
|
||||||
/#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g,
|
/#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem \+ var\(--world-timeline-lift, 0px\)\);/g,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
assert.ok(creditOffsets.length > 0);
|
assert.ok(creditOffsets.length > 0);
|
||||||
@@ -738,7 +738,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
cyberStyles,
|
cyberStyles,
|
||||||
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/,
|
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
cyberStyles,
|
cyberStyles,
|
||||||
@@ -793,7 +793,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
|
|||||||
cyberStyles,
|
cyberStyles,
|
||||||
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/,
|
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/,
|
||||||
);
|
);
|
||||||
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/);
|
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem \+ var\(--world-timeline-lift, 0px\)\);/);
|
||||||
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
|
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
|
||||||
assert.match(cyberStyles, /data-layer-id='satellites'/);
|
assert.match(cyberStyles, /data-layer-id='satellites'/);
|
||||||
assert.match(cyberStyles, /#search-toggle::before/);
|
assert.match(cyberStyles, /#search-toggle::before/);
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ test('the phone shell keeps the required attribution visible', () => {
|
|||||||
};
|
};
|
||||||
const lift = (block) =>
|
const lift = (block) =>
|
||||||
Number(
|
Number(
|
||||||
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1],
|
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px \+ var\(--world-timeline-lift, 0px\)\)/.exec(block)?.[1],
|
||||||
);
|
);
|
||||||
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
|
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
|
||||||
const idleCredit = rule(
|
const idleCredit = rule(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const LEFT_STACK_OBSTACLE_SELECTOR = [
|
|||||||
'#gev-voice-control',
|
'#gev-voice-control',
|
||||||
'#pp-toggles',
|
'#pp-toggles',
|
||||||
'#param-slider-panel',
|
'#param-slider-panel',
|
||||||
|
'#world-timeline',
|
||||||
].join(', ');
|
].join(', ');
|
||||||
/**
|
/**
|
||||||
* Fixed UI regions that can occupy the right control lane. Runtime rectangle
|
* Fixed UI regions that can occupy the right control lane. Runtime rectangle
|
||||||
@@ -59,6 +60,7 @@ const RIGHT_STACK_OBSTACLE_SELECTOR = [
|
|||||||
'#cesium-credits .cesium-credit-textContainer',
|
'#cesium-credits .cesium-credit-textContainer',
|
||||||
'#command-dock',
|
'#command-dock',
|
||||||
'#gev-voice-control',
|
'#gev-voice-control',
|
||||||
|
'#world-timeline',
|
||||||
].join(', ');
|
].join(', ');
|
||||||
export class PanelLayoutController {
|
export class PanelLayoutController {
|
||||||
constructor({
|
constructor({
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
|||||||
import { t, msg, formatDate } from '../i18n/index.js';
|
import { t, msg, formatDate } from '../i18n/index.js';
|
||||||
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
|
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
|
||||||
import { UiLifetime } from './uiLifetime.js';
|
import { UiLifetime } from './uiLifetime.js';
|
||||||
|
import { isWorldTime, worldClock } from '../time/worldClock.js';
|
||||||
|
|
||||||
|
// A link made away from live opens at its world time, paused. One without a
|
||||||
|
// world time leaves the clock as it is: a page opens live, and Back or Forward
|
||||||
|
// through long-form hashes (which never carry one) does not stop a replay.
|
||||||
|
function applySharedWorldTime(state) {
|
||||||
|
if (!isWorldTime(state?.worldTime)) return;
|
||||||
|
worldClock.pause();
|
||||||
|
worldClock.seek(state.worldTime);
|
||||||
|
}
|
||||||
|
|
||||||
/** How long a shared vessel may take to appear in the vessel feed. */
|
/** How long a shared vessel may take to appear in the vessel feed. */
|
||||||
export const SHARED_VESSEL_WAIT_MS = 120_000;
|
export const SHARED_VESSEL_WAIT_MS = 120_000;
|
||||||
@@ -146,6 +156,7 @@ export class ShareRestoration {
|
|||||||
: Promise.resolve({ status: 'not-requested', share: null, layers: [] });
|
: Promise.resolve({ status: 'not-requested', share: null, layers: [] });
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
this._hasShareState = true;
|
this._hasShareState = true;
|
||||||
|
applySharedWorldTime(savedState);
|
||||||
// Reserve camera authority now; the delayed mesh-friendly flight may
|
// Reserve camera authority now; the delayed mesh-friendly flight may
|
||||||
// run only if no newer user, voice, or tracking navigation has won.
|
// run only if no newer user, voice, or tracking navigation has won.
|
||||||
this._initialShareNavigationGeneration =
|
this._initialShareNavigationGeneration =
|
||||||
@@ -301,6 +312,7 @@ export class ShareRestoration {
|
|||||||
layers: [],
|
layers: [],
|
||||||
});
|
});
|
||||||
this._initialShareState = state;
|
this._initialShareState = state;
|
||||||
|
applySharedWorldTime(state);
|
||||||
this._initialShareSelectionSuperseded = false;
|
this._initialShareSelectionSuperseded = false;
|
||||||
this._hasShareState = true;
|
this._hasShareState = true;
|
||||||
this._navigatedShareRestoreActive = true;
|
this._navigatedShareRestoreActive = true;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { onScopeDispose, shallowRef } from 'vue';
|
||||||
|
import { worldClock } from '../../time/worldClock.js';
|
||||||
|
|
||||||
|
/** How often a shown timeline moves its readout and its end on with now. */
|
||||||
|
const LIVE_TICK_MS = 1000;
|
||||||
|
/** A scrub this close to now, as a share of the track, is a scrub to live. */
|
||||||
|
const LIVE_EDGE = 0.005;
|
||||||
|
/** The least that counts as at now, past the readout's own second. */
|
||||||
|
const LIVE_EDGE_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The world clock as the timeline shows it: `{live, playing, timeMs,
|
||||||
|
* supported, span}`, where `supported` says a layer that moves with the clock
|
||||||
|
* has time to show and `span` is the stretch the timeline runs over: the
|
||||||
|
* time those layers cover, reaching to now and to the world time, with their
|
||||||
|
* marks. It follows the page's world clock, which tells it of every move, and
|
||||||
|
* of the time a few times a second while it plays on its own. While a
|
||||||
|
* timeline shows, it reads the clock each second itself, so live and the
|
||||||
|
* track's end keep up with now.
|
||||||
|
*/
|
||||||
|
export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
||||||
|
const spanOf = (state) => {
|
||||||
|
if (!state.supported) return null;
|
||||||
|
const coverage = worldClock.coverage();
|
||||||
|
if (!coverage) return null;
|
||||||
|
const now = worldClock.wallNow();
|
||||||
|
return Object.freeze({
|
||||||
|
start: Math.min(coverage.start, now, state.timeMs),
|
||||||
|
end: Math.max(coverage.end, now, state.timeMs),
|
||||||
|
marks: coverage.marks,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const read = (state = worldClock.getState()) =>
|
||||||
|
Object.freeze({
|
||||||
|
live: state.live,
|
||||||
|
playing: state.playing,
|
||||||
|
timeMs: state.timeMs,
|
||||||
|
supported: state.supported,
|
||||||
|
span: spanOf(state),
|
||||||
|
});
|
||||||
|
const time = shallowRef(read());
|
||||||
|
let ticker = null;
|
||||||
|
const syncTicker = () => {
|
||||||
|
// Playing, the clock tells of the time itself.
|
||||||
|
const ticking = Boolean(time.value.span) && !time.value.playing;
|
||||||
|
if (ticking && ticker === null)
|
||||||
|
ticker = setInterval(() => {
|
||||||
|
time.value = read();
|
||||||
|
}, LIVE_TICK_MS);
|
||||||
|
else if (!ticking && ticker !== null) {
|
||||||
|
clearInterval(ticker);
|
||||||
|
ticker = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const unsubscribe = worldClock.subscribe((state) => {
|
||||||
|
time.value = read(state);
|
||||||
|
syncTicker();
|
||||||
|
});
|
||||||
|
syncTicker();
|
||||||
|
onScopeDispose(() => {
|
||||||
|
unsubscribe();
|
||||||
|
if (ticker !== null) clearInterval(ticker);
|
||||||
|
ticker = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
togglePlay: () => worldClock.togglePlay(),
|
||||||
|
goLive: () => worldClock.goLive(),
|
||||||
|
/** Scrub to `ms`: playback stops where the hand puts it; at now, live. */
|
||||||
|
scrub(ms) {
|
||||||
|
const span = time.value.span;
|
||||||
|
const edge = span ? (span.end - span.start) * LIVE_EDGE : 0;
|
||||||
|
if (Math.abs(ms - worldClock.wallNow()) <= Math.max(LIVE_EDGE_MS, edge)) {
|
||||||
|
worldClock.goLive();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
worldClock.pause();
|
||||||
|
worldClock.seek(ms);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -870,7 +870,7 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 146;
|
z-index: 146;
|
||||||
right: 36px;
|
right: 36px;
|
||||||
bottom: calc(2vh + 4.5rem);
|
bottom: calc(2vh + 4.5rem + var(--world-timeline-lift, 0px));
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
@@ -2049,7 +2049,7 @@ body.cockpit-mode #intel-hud .hud-right-edge {
|
|||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
#view-switcher {
|
#view-switcher {
|
||||||
right: 16px;
|
right: 16px;
|
||||||
bottom: 16px;
|
bottom: calc(16px + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
body.cockpit-mode #view-switcher {
|
body.cockpit-mode #view-switcher {
|
||||||
right: auto;
|
right: auto;
|
||||||
|
|||||||
@@ -377,15 +377,15 @@
|
|||||||
|
|
||||||
/* Compact dock clearance keeps the reference-like low profile. */
|
/* Compact dock clearance keeps the reference-like low profile. */
|
||||||
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
||||||
bottom: calc(2vh + 4.7rem);
|
bottom: calc(2vh + 4.7rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||||
bottom: calc(2vh + 5.4rem);
|
bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||||
bottom: calc(2vh + 8.9rem);
|
bottom: calc(2vh + 8.9rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
|||||||
)
|
)
|
||||||
#cesium-credits {
|
#cesium-credits {
|
||||||
right: auto;
|
right: auto;
|
||||||
bottom: calc(2vh + 5rem);
|
bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
|
||||||
left: 1.5rem;
|
left: 1.5rem;
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
max-width: calc(100vw - 3rem);
|
max-width: calc(100vw - 3rem);
|
||||||
@@ -464,21 +464,21 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
|||||||
|
|
||||||
#intel-hud[data-variant='tactical'] .hud-bottom-left,
|
#intel-hud[data-variant='tactical'] .hud-bottom-left,
|
||||||
#intel-hud[data-variant='tactical'] .hud-bottom-right {
|
#intel-hud[data-variant='tactical'] .hud-bottom-right {
|
||||||
bottom: calc(2vh + 6.5rem);
|
bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||||
bottom: calc(2vh + 5.4rem);
|
bottom: calc(2vh + 5.4rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-scope-mask='off']
|
:root[data-scope-mask='off']
|
||||||
#intel-hud[data-variant='minimal']
|
#intel-hud[data-variant='minimal']
|
||||||
.hud-bottom-left {
|
.hud-bottom-left {
|
||||||
bottom: calc(2vh + 6.5rem);
|
bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||||
bottom: calc(2vh + 9rem);
|
bottom: calc(2vh + 9rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -487,7 +487,7 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
|||||||
#intel-hud[data-variant='minimal'].active
|
#intel-hud[data-variant='minimal'].active
|
||||||
)
|
)
|
||||||
#cesium-credits {
|
#cesium-credits {
|
||||||
bottom: calc(2vh + 5rem);
|
bottom: calc(2vh + 5rem + var(--world-timeline-lift, 0px));
|
||||||
left: 0.75rem;
|
left: 0.75rem;
|
||||||
max-width: calc(100vw - 1.5rem);
|
max-width: calc(100vw - 1.5rem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
#command-dock {
|
#command-dock {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: auto;
|
right: auto;
|
||||||
bottom: 2vh;
|
/* Above the world timeline while it shows (world-timeline.css). */
|
||||||
|
bottom: calc(2vh + var(--world-timeline-lift, 0px));
|
||||||
left: 50%;
|
left: 50%;
|
||||||
z-index: 145;
|
z-index: 145;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -216,7 +217,7 @@
|
|||||||
|
|
||||||
/* Keep required map attribution clear of the full-width command surface. */
|
/* Keep required map attribution clear of the full-width command surface. */
|
||||||
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
||||||
bottom: calc(2vh + 7rem);
|
bottom: calc(2vh + 7rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not(.ui-clean-view):not(.recording-mode):has(
|
body:not(.ui-clean-view):not(.recording-mode):has(
|
||||||
@@ -244,11 +245,11 @@ body.recording-mode #cesium-credits {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||||
bottom: calc(2vh + 7.5rem);
|
bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||||
bottom: calc(2vh + 11.5rem);
|
bottom: calc(2vh + 11.5rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
@@ -275,21 +276,21 @@ body.recording-mode #cesium-credits {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
body:not(.ui-clean-view):not(.recording-mode) #cesium-credits {
|
||||||
bottom: calc(2vh + 9.5rem);
|
bottom: calc(2vh + 9.5rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
#intel-hud[data-variant='minimal'] .hud-bottom-left {
|
||||||
bottom: calc(2vh + 12rem);
|
bottom: calc(2vh + 12rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||||
bottom: calc(2vh + 16rem);
|
bottom: calc(2vh + 16rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
#command-dock {
|
#command-dock {
|
||||||
bottom: 8px;
|
bottom: calc(8px + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#command-dock .location-search-wrap {
|
#command-dock .location-search-wrap {
|
||||||
|
|||||||
@@ -1585,7 +1585,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='cyber'] .hud-bottom-bar {
|
#intel-hud[data-variant='cyber'] .hud-bottom-bar {
|
||||||
bottom: calc(2vh + 9.8rem);
|
bottom: calc(2vh + 9.8rem + var(--world-timeline-lift, 0px));
|
||||||
width: min(48vw, 680px);
|
width: min(48vw, 680px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1596,13 +1596,13 @@
|
|||||||
|
|
||||||
#intel-hud[data-variant='cyber'] .hud-bottom-left,
|
#intel-hud[data-variant='cyber'] .hud-bottom-left,
|
||||||
#intel-hud[data-variant='cyber'] .hud-bottom-right {
|
#intel-hud[data-variant='cyber'] .hud-bottom-right {
|
||||||
bottom: calc(2vh + 6.5rem);
|
bottom: calc(2vh + 6.5rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Attribution's logo row extends above its text baseline. Reserve the whole
|
/* Attribution's logo row extends above its text baseline. Reserve the whole
|
||||||
row plus a gap above the dock-relative 2vh + 5rem credit position. */
|
row plus a gap above the dock-relative 2vh + 5rem credit position. */
|
||||||
#intel-hud[data-variant='cyber'] .hud-bottom-left {
|
#intel-hud[data-variant='cyber'] .hud-bottom-left {
|
||||||
bottom: calc(2vh + 7.75rem);
|
bottom: calc(2vh + 7.75rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
#intel-hud[data-variant='cyber'] .hud-corner {
|
#intel-hud[data-variant='cyber'] .hud-corner {
|
||||||
@@ -1647,7 +1647,7 @@
|
|||||||
below its 650px threshold. Keep the left rail inside the same telemetry
|
below its 650px threshold. Keep the left rail inside the same telemetry
|
||||||
and attribution-safe floor while its panel bodies take over scrolling. */
|
and attribution-safe floor while its panel bodies take over scrolling. */
|
||||||
:root[data-ui-theme='cyber'] #left-panel-stack {
|
:root[data-ui-theme='cyber'] #left-panel-stack {
|
||||||
bottom: calc(2vh + 13rem);
|
bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1670,7 +1670,7 @@
|
|||||||
/* The stack holds still, so its rail needs no offset (leftPanelRail.js). */
|
/* The stack holds still, so its rail needs no offset (leftPanelRail.js). */
|
||||||
--panel-stack-top-pinned: 1;
|
--panel-stack-top-pinned: 1;
|
||||||
top: var(--cyber-left-rail-top) !important;
|
top: var(--cyber-left-rail-top) !important;
|
||||||
bottom: calc(2vh + 13rem);
|
bottom: calc(2vh + 13rem + var(--world-timeline-lift, 0px));
|
||||||
inset-inline-start: calc(54px + var(--panel-rail-clearance));
|
inset-inline-start: calc(54px + var(--panel-rail-clearance));
|
||||||
width: 300px;
|
width: 300px;
|
||||||
padding-inline-start: 10px;
|
padding-inline-start: 10px;
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ body.mobile-shell #command-dock {
|
|||||||
/* The dock's tray rules size voice by this basis; a column makes it a height. */
|
/* The dock's tray rules size voice by this basis; a column makes it a height. */
|
||||||
--dock-voice-width: auto;
|
--dock-voice-width: auto;
|
||||||
top: auto;
|
top: auto;
|
||||||
bottom: calc(var(--mobile-tab-space) + 10px);
|
bottom: calc(var(--mobile-tab-space) + 10px + var(--world-timeline-lift, 0px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -380,7 +380,7 @@ body.mobile-shell #command-dock #gev-voice-control {
|
|||||||
body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
|
body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
|
||||||
#cesium-credits {
|
#cesium-credits {
|
||||||
right: auto;
|
right: auto;
|
||||||
bottom: calc(var(--mobile-tab-space) + 106px) !important;
|
bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px)) !important;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
/* With the base rule's nowrap the credits run off the right edge, and a
|
/* With the base rule's nowrap the credits run off the right edge, and a
|
||||||
max-width alone wrapped them into a narrow column: the line needs wrapping
|
max-width alone wrapped them into a narrow column: the line needs wrapping
|
||||||
@@ -407,7 +407,7 @@ body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(
|
|||||||
/* The view switcher clears the tab bar and the voice bar, opposite the credit. */
|
/* The view switcher clears the tab bar and the voice bar, opposite the credit. */
|
||||||
body.mobile-shell:not(.cockpit-mode) #view-switcher {
|
body.mobile-shell:not(.cockpit-mode) #view-switcher {
|
||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: calc(var(--mobile-tab-space) + 106px);
|
bottom: calc(var(--mobile-tab-space) + 106px + var(--world-timeline-lift, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
body.mobile-shell #corner-actions {
|
body.mobile-shell #corner-actions {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
drawn straight over the notice. Track the credit's own anchor instead
|
drawn straight over the notice. Track the credit's own anchor instead
|
||||||
and the 12px of clear air holds regardless of height; the rail already
|
and the 12px of clear air holds regardless of height; the rail already
|
||||||
scrolls (overflow-y below), so it loses reach, not content. */
|
scrolls (overflow-y below), so it loses reach, not content. */
|
||||||
bottom: calc(2vh + 7.5rem);
|
bottom: calc(2vh + 7.5rem + var(--world-timeline-lift, 0px));
|
||||||
left: 16px;
|
left: 16px;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ html:not([lang='en']) #global-loading-detail {
|
|||||||
/* ── Toast Notification ──────────────────────── */
|
/* ── Toast Notification ──────────────────────── */
|
||||||
#toast {
|
#toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 100px;
|
bottom: calc(100px + var(--world-timeline-lift, 0px));
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) translateY(20px);
|
transform: translateX(-50%) translateY(20px);
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/* ── World timeline ─────────────────────────── */
|
||||||
|
/* Shown while a layer with time to show is on, centred along the bottom of
|
||||||
|
the screen; the command dock and what sits above it rise by its height. */
|
||||||
|
:root {
|
||||||
|
--world-timeline-lift: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(#world-timeline) {
|
||||||
|
--world-timeline-lift: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clean view, recording and the cockpit put the dock away; the bar goes too. */
|
||||||
|
body.ui-clean-view,
|
||||||
|
body.recording-mode,
|
||||||
|
body.cockpit-mode {
|
||||||
|
--world-timeline-lift: 0px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.ui-clean-view #world-timeline,
|
||||||
|
body.recording-mode #world-timeline,
|
||||||
|
body.cockpit-mode #world-timeline {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2vh;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 146;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: min(820px, calc(100vw - 24px));
|
||||||
|
height: 42px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.4);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-play,
|
||||||
|
#world-timeline .world-timeline-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid rgba(123, 189, 211, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 212, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-play:hover,
|
||||||
|
#world-timeline .world-timeline-live:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-live.is-live {
|
||||||
|
border-color: rgba(0, 212, 255, 0.6);
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-live {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-time {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-edge {
|
||||||
|
flex: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The track: ticks for each launch or frame, the elapsed fill, and a native
|
||||||
|
range input on top for dragging and the keyboard. */
|
||||||
|
#world-timeline .world-timeline-track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 80px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-track::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(123, 189, 211, 0.22);
|
||||||
|
content: '';
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-tick {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 2px;
|
||||||
|
height: 12px;
|
||||||
|
margin-left: -1px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: rgba(0, 212, 255, 0.75);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-scrub {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-scrub::-webkit-slider-runnable-track {
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-scrub::-moz-range-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-scrub::-webkit-slider-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin-top: 5px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #031018;
|
||||||
|
box-shadow: 0 0 8px rgba(0, 212, 255, 0.6);
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-scrub::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #031018;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-scrub:focus-visible {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
#world-timeline {
|
||||||
|
bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On phones the bar sits just above the tab bar, under the voice dock, with
|
||||||
|
the track on a row of its own beneath play, the time and live. */
|
||||||
|
body.mobile-shell:has(#world-timeline) {
|
||||||
|
--world-timeline-lift: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-shell #world-timeline {
|
||||||
|
bottom: calc(var(--mobile-tab-space) + 6px);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 4px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-shell #world-timeline .world-timeline-time {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.mobile-shell #world-timeline .world-timeline-track {
|
||||||
|
order: 1;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
#world-timeline .world-timeline-edge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#world-timeline .world-timeline-time {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue';
|
|||||||
import AwarenessDirection from './panels/AwarenessDirection.vue';
|
import AwarenessDirection from './panels/AwarenessDirection.vue';
|
||||||
import UiButton from '../kit/UiButton.vue';
|
import UiButton from '../kit/UiButton.vue';
|
||||||
import GlobeActions from './panels/GlobeActions.vue';
|
import GlobeActions from './panels/GlobeActions.vue';
|
||||||
|
import WorldTimeline from './panels/WorldTimeline.vue';
|
||||||
|
|
||||||
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
// The ACTIVE STYLE corner shows the style VisualSettings publishes.
|
||||||
const { style } = storeToRefs(useDisplayStore());
|
const { style } = storeToRefs(useDisplayStore());
|
||||||
@@ -117,4 +118,5 @@ const vToastText = {
|
|||||||
<MissionReplayOverlay />
|
<MissionReplayOverlay />
|
||||||
<ScreenAnnotations />
|
<ScreenAnnotations />
|
||||||
<AwarenessDirection />
|
<AwarenessDirection />
|
||||||
|
<WorldTimeline />
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// The world timeline shows the world time while a layer with time to show is
|
||||||
|
// on, with play and pause on the left and the way back to live on the right.
|
||||||
|
import '../../../testSupport/registerDom.mjs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createApp, nextTick } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import { worldClock } from '../../../time/worldClock.js';
|
||||||
|
import WorldTimeline from './WorldTimeline.vue';
|
||||||
|
import { useWorldTimeStore } from '../../stores/worldTime.js';
|
||||||
|
|
||||||
|
function mount(t) {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
const pinia = createPinia();
|
||||||
|
const app = createApp(WorldTimeline).use(pinia);
|
||||||
|
app.mount(document.body.appendChild(document.createElement('div')));
|
||||||
|
t.after(() => {
|
||||||
|
app.unmount();
|
||||||
|
// The store outlives the view; dispose it with its clock subscription.
|
||||||
|
useWorldTimeStore(pinia).$dispose();
|
||||||
|
worldClock.goLive();
|
||||||
|
});
|
||||||
|
return () => document.getElementById('world-timeline');
|
||||||
|
}
|
||||||
|
|
||||||
|
// A layer that moves with the clock and has an hour of frames.
|
||||||
|
function supportingLayer(t, at) {
|
||||||
|
const release = worldClock.hold('test-radar');
|
||||||
|
const unregister = worldClock.register({
|
||||||
|
id: 'test-radar',
|
||||||
|
coverage: () => ({ start: at - 3_600_000, end: at }),
|
||||||
|
});
|
||||||
|
t.after(() => {
|
||||||
|
unregister();
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unregister();
|
||||||
|
release();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the timeline shows live, and plays, pauses and goes back', async (t) => {
|
||||||
|
const bar = mount(t);
|
||||||
|
const at = Date.UTC(2026, 9, 30, 6, 15, 42);
|
||||||
|
supportingLayer(t, at);
|
||||||
|
await nextTick();
|
||||||
|
assert.ok(bar(), 'shown while live');
|
||||||
|
const live = bar().querySelector('.world-timeline-live');
|
||||||
|
assert.equal(live.disabled, true, 'already live');
|
||||||
|
assert.equal(live.getAttribute('aria-pressed'), 'true');
|
||||||
|
|
||||||
|
worldClock.seek(at);
|
||||||
|
await nextTick();
|
||||||
|
assert.ok(bar());
|
||||||
|
const time = bar().querySelector('time');
|
||||||
|
assert.equal(time.getAttribute('datetime'), new Date(at).toISOString());
|
||||||
|
assert.match(time.textContent, /UTC$/);
|
||||||
|
const play = bar().querySelector('.world-timeline-play');
|
||||||
|
assert.equal(play.getAttribute('aria-label'), 'Play');
|
||||||
|
|
||||||
|
play.click();
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(worldClock.isPlaying(), true);
|
||||||
|
assert.equal(play.getAttribute('aria-label'), 'Pause');
|
||||||
|
play.click();
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(worldClock.isPlaying(), false);
|
||||||
|
|
||||||
|
assert.equal(bar().querySelector('.world-timeline-live').disabled, false);
|
||||||
|
bar().querySelector('.world-timeline-live').click();
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(worldClock.isLive(), true);
|
||||||
|
assert.ok(bar(), 'still shown, live');
|
||||||
|
assert.equal(bar().querySelector('.world-timeline-live').disabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the timeline hides while no layer with time to show is on', async (t) => {
|
||||||
|
const bar = mount(t);
|
||||||
|
const at = Date.UTC(2026, 9, 30, 6, 15, 42);
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(bar(), null, 'live, nothing to play');
|
||||||
|
worldClock.seek(at);
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(bar(), null, 'away from live, but nothing to play');
|
||||||
|
const remove = supportingLayer(t, at);
|
||||||
|
await nextTick();
|
||||||
|
assert.ok(bar());
|
||||||
|
remove();
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(bar(), null, 'the layer went off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the track runs over the time the layers show, to now, and scrubs', async (t) => {
|
||||||
|
const bar = mount(t);
|
||||||
|
const now = Date.now();
|
||||||
|
const at = now - 2 * 86_400_000;
|
||||||
|
const release = worldClock.hold('test-launches');
|
||||||
|
const unregister = worldClock.register({
|
||||||
|
id: 'test-launches',
|
||||||
|
coverage: () => ({ start: at - 86_400_000, end: at, marks: [at - 86_400_000, at] }),
|
||||||
|
});
|
||||||
|
t.after(() => {
|
||||||
|
unregister();
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
const input = bar().querySelector('input[type=range]');
|
||||||
|
assert.equal(Number(input.min), at - 86_400_000);
|
||||||
|
assert.ok(Number(input.max) >= now, 'reaches to now');
|
||||||
|
assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 2);
|
||||||
|
|
||||||
|
worldClock.play();
|
||||||
|
input.value = String(at);
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(worldClock.isPlaying(), false, 'a scrub stops playback');
|
||||||
|
assert.equal(worldClock.now(), at);
|
||||||
|
assert.equal(bar().querySelector('time').getAttribute('datetime'), new Date(at).toISOString());
|
||||||
|
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(worldClock.now(), at - 86_400_000, 'the arrow goes to the previous mark');
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(worldClock.now(), at, 'and back to the next');
|
||||||
|
|
||||||
|
input.value = String(Date.now() + 2_000);
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(worldClock.isLive(), true, 'a scrub to now goes live');
|
||||||
|
|
||||||
|
// A layer that reaches ahead of now, as satellites do.
|
||||||
|
const holdAhead = worldClock.hold('test-ahead');
|
||||||
|
const unregisterAhead = worldClock.register({
|
||||||
|
id: 'test-ahead',
|
||||||
|
coverage: () => ({ start: now, end: now + 3 * 86_400_000 }),
|
||||||
|
});
|
||||||
|
t.after(() => {
|
||||||
|
unregisterAhead();
|
||||||
|
holdAhead();
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
const ahead = Date.now() + 86_400_000;
|
||||||
|
input.value = String(ahead);
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(worldClock.isLive(), false, 'a scrub past now stays there');
|
||||||
|
assert.equal(worldClock.now(), ahead);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { formatDate, t } from '../../../i18n/index.js';
|
||||||
|
import { useWorldTimeStore } from '../../stores/worldTime.js';
|
||||||
|
import UiButton from '../../kit/UiButton.vue';
|
||||||
|
import UiIcon from '../../kit/UiIcon.vue';
|
||||||
|
|
||||||
|
// The world timeline: play and pause on the left, the time, a track across
|
||||||
|
// the stretch the layers on can show (each launch or frame a tick) with a
|
||||||
|
// handle to scrub, and the way back to live on the right, lit while live. It
|
||||||
|
// shows while a layer with a time of its own (weather, satellites, launches,
|
||||||
|
// or one a host follows) is on with time to show; with only live feeds such
|
||||||
|
// as flights on, the bar stays hidden.
|
||||||
|
const store = useWorldTimeStore();
|
||||||
|
const { time } = storeToRefs(store);
|
||||||
|
|
||||||
|
const DATE_TIME = Object.freeze({
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
const EDGE_DATE = Object.freeze({
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
// The keyboard's fallback step, as a share of the track, with no mark to go to.
|
||||||
|
const KEY_STEP = 0.01;
|
||||||
|
const PAGE_STEP = 0.1;
|
||||||
|
|
||||||
|
const readout = computed(() =>
|
||||||
|
Number.isFinite(time.value.timeMs)
|
||||||
|
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC`
|
||||||
|
: '',
|
||||||
|
);
|
||||||
|
const span = computed(() => time.value.span);
|
||||||
|
const at = computed(() => {
|
||||||
|
const { start, end } = span.value;
|
||||||
|
return Math.min(end, Math.max(start, time.value.timeMs));
|
||||||
|
});
|
||||||
|
const place = (ms) => {
|
||||||
|
const { start, end } = span.value;
|
||||||
|
return end > start ? ((ms - start) / (end - start)) * 100 : 100;
|
||||||
|
};
|
||||||
|
const ticks = computed(() =>
|
||||||
|
span.value.marks.map((mark) => `${place(mark)}%`),
|
||||||
|
);
|
||||||
|
const scrub = (event) => store.scrub(Number(event.target.value));
|
||||||
|
// Arrows go to the previous or next mark (a launch, a frame), or a step along
|
||||||
|
// the track; Page keys take a tenth of it; Home and End go to its ends.
|
||||||
|
const onKey = (event) => {
|
||||||
|
const { start, end, marks } = span.value;
|
||||||
|
const now = at.value;
|
||||||
|
const length = end - start;
|
||||||
|
let to = null;
|
||||||
|
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown')
|
||||||
|
to = marks.findLast((mark) => mark < now) ?? now - length * KEY_STEP;
|
||||||
|
else if (event.key === 'ArrowRight' || event.key === 'ArrowUp')
|
||||||
|
to = marks.find((mark) => mark > now) ?? now + length * KEY_STEP;
|
||||||
|
else if (event.key === 'PageDown') to = now - length * PAGE_STEP;
|
||||||
|
else if (event.key === 'PageUp') to = now + length * PAGE_STEP;
|
||||||
|
else if (event.key === 'Home') to = start;
|
||||||
|
else if (event.key === 'End') to = end;
|
||||||
|
if (to === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
store.scrub(Math.min(end, Math.max(start, to)));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="time.supported && time.span" id="world-timeline" role="group" :aria-label="t('World time')"
|
||||||
|
><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()"
|
||||||
|
><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton
|
||||||
|
><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
|
||||||
|
><span class="world-timeline-edge">{{ formatDate(span.start, EDGE_DATE) }}</span
|
||||||
|
><div class="world-timeline-track"
|
||||||
|
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
|
||||||
|
><span class="world-timeline-fill" :style="{ width: `${place(at)}%` }" aria-hidden="true"></span
|
||||||
|
><input
|
||||||
|
class="world-timeline-scrub"
|
||||||
|
type="range"
|
||||||
|
:aria-label="t('World time')"
|
||||||
|
:aria-valuetext="readout"
|
||||||
|
:min="span.start"
|
||||||
|
:max="span.end"
|
||||||
|
step="any"
|
||||||
|
:value="at"
|
||||||
|
@input="scrub"
|
||||||
|
@keydown="onKey"
|
||||||
|
/></div
|
||||||
|
><span class="world-timeline-edge">{{ formatDate(span.end, EDGE_DATE) }}</span
|
||||||
|
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
@@ -74,9 +74,11 @@ test('bikeshare stations join coordinates from station information', () => {
|
|||||||
assert.deepEqual(methods.getAnalystRecords(), []);
|
assert.deepEqual(methods.getAnalystRecords(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('launches carry time and hours until launch', () => {
|
test('launches carry time and hours until launch at the world time', () => {
|
||||||
const at = Date.now() + 5 * 3_600_000;
|
const now = Date.UTC(2026, 10, 1);
|
||||||
|
const at = now + 5 * 3_600_000;
|
||||||
const state = {
|
const state = {
|
||||||
|
now: () => now,
|
||||||
_enabled: true,
|
_enabled: true,
|
||||||
_launches: [
|
_launches: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
@import './src/ui/styles/provider-settings.css';
|
@import './src/ui/styles/provider-settings.css';
|
||||||
@import './src/ui/styles/locale-picker.css';
|
@import './src/ui/styles/locale-picker.css';
|
||||||
@import './src/ui/styles/weather.css';
|
@import './src/ui/styles/weather.css';
|
||||||
|
@import './src/ui/styles/world-timeline.css';
|
||||||
@import './src/ui/styles/panel-surfaces.css';
|
@import './src/ui/styles/panel-surfaces.css';
|
||||||
@import './src/ui/styles/segmented-control.css';
|
@import './src/ui/styles/segmented-control.css';
|
||||||
@import './src/ui/styles/kit.css';
|
@import './src/ui/styles/kit.css';
|
||||||
|
|||||||
Reference in new issue
Block a user