mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Move satellites, launches and the sky with the world time
Layers whose data has a time of its own follow the page's world clock from the start; live feeds such as flights, military, vessels and transit stay live. Launches register the span of the launches loaded, so turning them on brings up the timeline. Each timed layer registers its coverage once and reports none while off, telling the clock after each step has settled. The launch feed keeps its own window, and an orbit ring is drawn again once the world time has moved far from when it was drawn.
This commit is contained in:
1 parent
690a6637e2
commit
3534398771
14 files changed
+123
-45
No files matched your search
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import { worldClock } from '../time/worldClock.js';
|
||||
import { createModel } from '../layers/launches/model.js';
|
||||
import rocketLaunchesLayer, {
|
||||
_setRocketMissionOverlayHostForTest,
|
||||
@@ -1228,7 +1229,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
rocketLaunchesLayer.init(viewer);
|
||||
initialized = true;
|
||||
await rocketLaunchesLayer.enable();
|
||||
assert.equal(worldClock.coverage(), null, 'nothing loaded, no time to show');
|
||||
await rocketLaunchesLayer.update();
|
||||
const at = Date.parse(launchTime);
|
||||
assert.deepEqual(worldClock.coverage(), { start: at, end: at, marks: [at] });
|
||||
assert.equal(worldClock.isSupported(), true, 'loaded launches bring up the timeline');
|
||||
|
||||
const entities = dataSources[0].entities.values;
|
||||
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path');
|
||||
@@ -1367,6 +1372,7 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
assert.equal(deselectedPublication[2][0].protected, false);
|
||||
|
||||
await rocketLaunchesLayer.disable();
|
||||
assert.equal(worldClock.coverage(), null, 'off, launches show no time');
|
||||
assert.deepEqual(hostCalls.slice(-4), [
|
||||
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
|
||||
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
|
||||
@@ -1488,3 +1494,11 @@ test('missing payload details stay unknown without inventing mass or classificat
|
||||
const [launch] = normalizeRocketLaunches([{ id: 'missing', pad: { latitude: 1, longitude: 2 }, net: '2026-07-20T10:00:00Z' }], NOW);
|
||||
assert.deepEqual(launch.payloads, []);
|
||||
});
|
||||
|
||||
test('away from live the feed keeps its own window of launches', (t) => {
|
||||
t.after(() => worldClock.goLive());
|
||||
worldClock.seek(Date.now() - 20 * 86_400_000);
|
||||
const net = new Date(Date.now() - 86_400_000).toISOString();
|
||||
const launches = normalizeRocketLaunches([{ id: 'recent', pad: { latitude: 1, longitude: 2 }, net }]);
|
||||
assert.deepEqual(launches.map((launch) => launch.id), ['recent']);
|
||||
});
|
||||
Vendored
+1
-1
@@ -26,7 +26,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
getAnalystRecords(maxCount = Infinity) {
|
||||
if (!layerState._enabled) return [];
|
||||
const now = Date.now();
|
||||
const now = layerState.now();
|
||||
const text = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||
const result = [];
|
||||
for (const launch of layerState._launches || []) {
|
||||
|
||||
@@ -222,6 +222,7 @@ export function createIngestion({
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastError = null;
|
||||
layerState._stale = stale === true;
|
||||
layerState.coverageChanged();
|
||||
} catch (error) {
|
||||
if (layerState._enabled && token === layerState._lifecycleToken) {
|
||||
layerState._lastError = error.message;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
|
||||
@@ -119,6 +120,21 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
let hover = null;
|
||||
// While on, the launches loaded span the time from the first to the last,
|
||||
// each one a mark. Registered once; off, the layer has no time to show, and
|
||||
// each step tells the clock once it has settled.
|
||||
worldClock.register({
|
||||
id: 'rocket-launches',
|
||||
coverage: () => {
|
||||
if (!layerState._enabled) return null;
|
||||
const marks = layerState._launches
|
||||
.map((launch) => Date.parse(launch.launchTime))
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
if (!marks.length) return null;
|
||||
return { start: marks[0], end: marks.at(-1), marks };
|
||||
},
|
||||
});
|
||||
|
||||
const methods = {
|
||||
init(viewer) {
|
||||
@@ -194,6 +210,7 @@ export function createLifecycle({
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
async _enableBody() {
|
||||
@@ -231,6 +248,7 @@ export function createLifecycle({
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
await parts.ingestion.restoreSatelliteDependency();
|
||||
parts.panel.renderMissionPanel();
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
async destroy(viewer) {
|
||||
|
||||
@@ -175,7 +175,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
|
||||
function normalizeRocketLaunches(payload, now = new Date(layerState.now())) {
|
||||
function normalizeRocketLaunches(payload, now = new Date()) {
|
||||
const launches = Array.isArray(payload) ? payload : payload?.results;
|
||||
if (!Array.isArray(launches)) return [];
|
||||
const cutoff = now.getTime() - WINDOW_DAYS * 86400000;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createState({ services }) {
|
||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
||||
@@ -156,6 +156,10 @@ export function createState({ services }) {
|
||||
|
||||
state.orbitNow = worldTimeFor('satellites');
|
||||
|
||||
/** The launches loaded changed, and with them the time they cover. */
|
||||
|
||||
state.coverageChanged = () => worldClock.coverageChanged();
|
||||
|
||||
state._missionOrbitPatternRegistered = false;
|
||||
|
||||
state._pathDistanceCache = new WeakMap();
|
||||
|
||||
@@ -170,7 +170,6 @@ export function createIngestion({
|
||||
layerState._coreStale = coreStale;
|
||||
layerState._count = layerState._points.size;
|
||||
layerState._catalogRevision++;
|
||||
layerState.coverageChanged();
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._lastPropagation = Date.now();
|
||||
layerState._lastTrackingRefreshOutcome = {
|
||||
@@ -194,6 +193,7 @@ export function createIngestion({
|
||||
// the rebuild must not block on that.
|
||||
void parts.tracking._reconcileTrackedSubjectContext();
|
||||
parts.tracking._applyPendingTrackingRestore();
|
||||
layerState.coverageChanged();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Satellite update aborted', 'AbortError');
|
||||
|
||||
@@ -10,30 +10,19 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { clearFocusTarget } = services.focus;
|
||||
// While on with a catalog, satellites can be shown at any time in reach of
|
||||
// their element sets.
|
||||
let releaseCoverage = null;
|
||||
const coverWorldTime = () => {
|
||||
if (releaseCoverage) return;
|
||||
const release = worldClock.register({
|
||||
id: 'satellites',
|
||||
coverage: () => {
|
||||
if (!layerState._catalog.size) return null;
|
||||
const now = worldClock.wallNow();
|
||||
return {
|
||||
start: now - SATELLITE_COVERAGE_MS,
|
||||
end: now + SATELLITE_COVERAGE_MS,
|
||||
};
|
||||
},
|
||||
});
|
||||
// Registering tells the world's listeners, which may turn the layer off
|
||||
// (or off and on, registering again) before this returns.
|
||||
if (layerState._enabled && !releaseCoverage) releaseCoverage = release;
|
||||
else release();
|
||||
};
|
||||
const uncoverWorldTime = () => {
|
||||
releaseCoverage?.();
|
||||
releaseCoverage = null;
|
||||
};
|
||||
// their element sets. Registered once; off, the layer has no time to show,
|
||||
// and each step tells the clock once it has settled.
|
||||
worldClock.register({
|
||||
id: 'satellites',
|
||||
coverage: () => {
|
||||
if (!layerState._enabled || !layerState._catalog.size) return null;
|
||||
const now = worldClock.wallNow();
|
||||
return {
|
||||
start: now - SATELLITE_COVERAGE_MS,
|
||||
end: now + SATELLITE_COVERAGE_MS,
|
||||
};
|
||||
},
|
||||
});
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
@@ -91,7 +80,6 @@ export function createLifecycle({
|
||||
|
||||
enable(viewer) {
|
||||
layerState._enabled = true;
|
||||
coverWorldTime();
|
||||
holdContinuousRender('satellites'); // per-frame animator (perf wave 2)
|
||||
if (layerState._pointCollection)
|
||||
layerState._pointCollection.show =
|
||||
@@ -120,13 +108,13 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
parts.tracking._applyPendingTrackingRestore();
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
disable(viewer) {
|
||||
parts.catalog._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
layerState._enabled = false;
|
||||
uncoverWorldTime();
|
||||
releaseContinuousRender('satellites');
|
||||
if (layerState._pointCollection) layerState._pointCollection.show = false;
|
||||
for (const path of layerState._orbitPaths.values())
|
||||
@@ -148,13 +136,13 @@ export function createLifecycle({
|
||||
layerState._preRenderListener();
|
||||
layerState._preRenderListener = null;
|
||||
}
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
parts.catalog._abortActiveUpdates();
|
||||
releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix)
|
||||
layerState._enabled = false;
|
||||
uncoverWorldTime();
|
||||
parts.tracking._clearTracking();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (layerState._clickHandler) {
|
||||
@@ -191,7 +179,6 @@ export function createLifecycle({
|
||||
layerState._denseStatus = 'idle';
|
||||
layerState._denseError = null;
|
||||
layerState._catalogRevision++;
|
||||
layerState.coverageChanged();
|
||||
layerState._rowControlsListener = null;
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
@@ -207,6 +194,7 @@ export function createLifecycle({
|
||||
failedGroups: [],
|
||||
};
|
||||
layerState._viewer = null;
|
||||
layerState.coverageChanged();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ export const ORBIT_PATH_STEPS = 180;
|
||||
export const POSITION_UPDATE_MS = 1000;
|
||||
/** How far either side of now SGP4 on current element sets stays meaningful. */
|
||||
export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000;
|
||||
|
||||
/**
|
||||
* How far the world time may move from when an orbit ring was drawn before it
|
||||
* is drawn again: the Earth's turn is followed exactly, but the orbit's plane
|
||||
* drifts a few degrees a day.
|
||||
*/
|
||||
export const ORBIT_REBAKE_MS = 30 * 60_000;
|
||||
// re-propagate every 1s (SGP4 is smooth at this rate)
|
||||
|
||||
export const RING_ROTATION_MS = 1000;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js';
|
||||
import {
|
||||
ISS_NORAD,
|
||||
ORBIT_REBAKE_MS,
|
||||
POSITION_UPDATE_MS,
|
||||
RING_ROTATION_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
@@ -75,6 +80,8 @@ export function createRendering({
|
||||
|
||||
layerState._orbitPaths.set(noradId, {
|
||||
primitive,
|
||||
color: pathColor,
|
||||
bakeMs: bakeDate.getTime(),
|
||||
// Same Date object as computeOrbitPath's internal fixedGmst → identical
|
||||
// GMST value (gstime is pure), so delta starts at exactly 0 and the
|
||||
// initial identity modelMatrix is correct until the first ring tick.
|
||||
@@ -113,8 +120,15 @@ export function createRendering({
|
||||
function _updateOrbitPathRotations(nowDate) {
|
||||
if (layerState._orbitPaths.size === 0) return;
|
||||
|
||||
for (const path of layerState._orbitPaths.values()) {
|
||||
for (const [noradId, path] of [...layerState._orbitPaths]) {
|
||||
if (!path.primitive) continue;
|
||||
// The rotation keeps the ring under the dot for minutes; the plane
|
||||
// itself precesses, so a world time moved far from the bake re-bakes.
|
||||
if (Math.abs(nowDate.getTime() - path.bakeMs) > ORBIT_REBAKE_MS) {
|
||||
_hideOrbitPath(noradId);
|
||||
_showOrbitPath(noradId, path.color);
|
||||
continue;
|
||||
}
|
||||
parts.orbits.orbitFrameModelMatrix(
|
||||
path.gmstAtBake,
|
||||
nowDate,
|
||||
|
||||
+18
-6
@@ -6,8 +6,10 @@
|
||||
* Live is a mode, not a timestamp: while live the clock is the wall clock and
|
||||
* every layer behaves as it would reading `Date.now()`. Away from live the
|
||||
* clock holds a cursor, fixed or playing. Only the layers that follow the clock
|
||||
* (`follow(id)`) move with the cursor; the rest keep reading the live edge
|
||||
* through `timeFor(id)`, so a host opts layers in one at a time.
|
||||
* 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
|
||||
@@ -28,7 +30,9 @@ export const WORLD_CLOCK_TICK_MS = 250;
|
||||
/**
|
||||
* @param {{now?: () => number, monotonicNow?: () => number,
|
||||
* setInterval?: typeof globalThis.setInterval,
|
||||
* clearInterval?: typeof globalThis.clearInterval}} [options]
|
||||
* 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)
|
||||
@@ -37,10 +41,11 @@ export function createWorldClock({
|
||||
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();
|
||||
const following = new Set(follow);
|
||||
const holds = new Map();
|
||||
const readers = new Map();
|
||||
const sources = new Set();
|
||||
@@ -330,8 +335,15 @@ export function createWorldClock({
|
||||
return api;
|
||||
}
|
||||
|
||||
/** The page's world clock. Layers, the sky and a host all read this one. */
|
||||
export const worldClock = createWorldClock();
|
||||
/**
|
||||
* The page's world clock. Layers, the sky and a host all read this one. The
|
||||
* sky, 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', 'satellites', 'rocket-launches'],
|
||||
});
|
||||
|
||||
/** The time layer `id` draws at on the page's clock. */
|
||||
export const worldTimeFor = (id) => worldClock.timeFor(id);
|
||||
@@ -55,6 +55,24 @@ test('away from live only following layers move with the cursor', () => {
|
||||
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', '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);
|
||||
|
||||
@@ -8,9 +8,9 @@ import UiIcon from '../../kit/UiIcon.vue';
|
||||
|
||||
// The world time while it is away from live: what is playing, or where it
|
||||
// was paused, with play and pause on the left and the way back to live on
|
||||
// the right. It shows only while a layer that moves with the clock (weather
|
||||
// radar, satellite or lightning, or one a host follows) is on with time to
|
||||
// show; live, or with none on, the bar stays hidden.
|
||||
// the right. It shows only while a layer with a time of its own (weather,
|
||||
// satellites, launches, or one a host follows) is on with time to show; live,
|
||||
// or with only live feeds such as flights on, the bar stays hidden.
|
||||
const store = useWorldTimeStore();
|
||||
const { time } = storeToRefs(store);
|
||||
|
||||
|
||||
@@ -74,9 +74,11 @@ test('bikeshare stations join coordinates from station information', () => {
|
||||
assert.deepEqual(methods.getAnalystRecords(), []);
|
||||
});
|
||||
|
||||
test('launches carry time and hours until launch', () => {
|
||||
const at = Date.now() + 5 * 3_600_000;
|
||||
test('launches carry time and hours until launch at the world time', () => {
|
||||
const now = Date.UTC(2026, 10, 1);
|
||||
const at = now + 5 * 3_600_000;
|
||||
const state = {
|
||||
now: () => now,
|
||||
_enabled: true,
|
||||
_launches: [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user