Files
gods-eye-view/src/layers/satellites/catalog.js
T
Sameh Khamis 0c4da3cdb2 Keep one world clock that every timed layer reads
Core told time eight ways: Date.now() in most layers, JulianDate.now() for
flights and military, Cesium's clock frozen at page load for the sky, the
weather clock for observed imagery. They now read one clock,
src/time/worldClock.js, exported as ./time.

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

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

243 lines
8.9 KiB
JavaScript

import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import {
DENSE_REFRESH_FRAMES,
DENSE_GROUP_PATH,
POINT_STYLES,
DENSE_CREATE_CHUNK,
} from './policy.js';
export function createCatalog({ state: layerState, services, parts, source }) {
function _abortActiveUpdates() {
for (const controller of layerState._activeUpdateControllers)
controller.abort();
layerState._activeUpdateControllers.clear();
layerState._denseLoadController?.abort();
layerState._denseLoadController = null;
}
/**
* Re-propagate a small per-frame slice of the dense extras (round-robin).
* Budget: the full dense set completes one pass every ~DENSE_REFRESH_FRAMES
* frames (~5s at 60fps ≈ 1/5 of the core cadence), so per-frame cost stays
* ~35 propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the
* work per frame avoids the once-per-second spike a tick-sized chunk
* (~2K props ≈ 4ms) would cause.
*/
function _propagateDenseChunk() {
if (layerState._denseIds.length === 0) return;
const perFrame = Math.max(
1,
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
);
const now = new Date(layerState.now());
for (let i = 0; i < perFrame; i++) {
if (layerState._denseCursor >= layerState._denseIds.length)
layerState._denseCursor = 0;
const noradId = layerState._denseIds[layerState._denseCursor++];
if (noradId === layerState._trackedNorad) continue; // per-frame tracked path owns it
const sat = layerState._catalog.get(noradId);
const point = layerState._points.get(noradId);
if (!sat || !point) continue;
const pos = parts.orbits.propagatePosition(sat.satrec, now);
if (pos) {
point.position = Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
);
}
}
}
/**
* Load the dense catalog extras (Starlink) as points-only satellites.
* Chunked so ~10K twoline2satrec builds + initial propagations never block a
* frame; a token guards against mode flips / catalog rebuilds mid-load.
*/
async function _loadDenseCatalog({ signal = null } = {}) {
if (!layerState._viewer || !layerState._pointCollection)
return { status: 'source-unavailable', reason: 'layer-unavailable' };
layerState._denseLoadController?.abort();
const resourceController = new AbortController();
layerState._denseLoadController = resourceController;
const loadSignal = signal
? AbortSignal.any([signal, resourceController.signal])
: resourceController.signal;
const token = ++layerState._denseLoadToken;
layerState._denseStatus = 'loading';
layerState._denseError = null;
parts.controls._notifyRowControls();
try {
loadSignal.throwIfAborted();
const res = await source.readGroup(DENSE_GROUP_PATH, {
signal: loadSignal,
});
if (!res.ok) {
console.warn(
`[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' fetch failed (${res.status})`,
);
_denseLoadFailed(token, `feed unavailable (${res.status})`);
return {
status: 'source-unavailable',
reason: `feed unavailable (${res.status})`,
};
}
const text = res.text;
loadSignal.throwIfAborted();
if (
token !== layerState._denseLoadToken ||
layerState._params.catalog !== 'dense'
) {
return { status: 'superseded', reason: 'dense-load-superseded' };
}
const entries = parts.orbits.parseTLE(text);
const style = POINT_STYLES.dense;
const now = new Date(layerState.now());
let added = 0;
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
loadSignal.throwIfAborted();
if (
token !== layerState._denseLoadToken ||
layerState._params.catalog !== 'dense' ||
!layerState._pointCollection
) {
return { status: 'superseded', reason: 'dense-load-superseded' };
}
const end = Math.min(start + DENSE_CREATE_CHUNK, entries.length);
for (let i = start; i < end; i++) {
const entry = entries[i];
const satrec = twoline2satrec(entry.line1, entry.line2);
if (!satrec || satrec.error !== 0) continue;
const noradId = noradIdFromSatnum(satrec.satnum);
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
const pos = parts.orbits.propagatePosition(satrec, now);
if (!pos) continue;
layerState._catalog.set(noradId, {
name: entry.name,
satrec,
group: 'dense',
});
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
),
pixelSize: style.pixelSize,
color: style.color,
outlineColor: style.outlineColor,
outlineWidth: style.outlineWidth,
scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6),
id: noradId,
});
layerState._points.set(noradId, point);
layerState._denseIds.push(noradId);
layerState._denseStale ||= Boolean(res.stale);
added++;
}
// Yield to the event loop between chunks.
await new Promise((resolve) => setTimeout(resolve, 0));
loadSignal.throwIfAborted();
}
// A 200 that yields nothing usable is still a failed load — an empty body,
// an HTML error page the proxy passed through, or a feed of TLEs the core
// catalog already owns. Treating it as success is the same lie as treating
// a 502 as success, just through a different door.
if (added === 0) {
console.warn(
`[Data:Satellites] Dense group '${DENSE_GROUP_PATH}' returned no usable satellites`,
);
_denseLoadFailed(token, 'feed returned no satellites');
return {
status: 'source-unavailable',
reason: 'feed returned no satellites',
};
}
layerState._count = layerState._points.size;
layerState._catalogRevision++;
layerState._denseStatus = 'ready';
console.log(
`[Data:Satellites] Dense catalog: +${added} ${DENSE_GROUP_PATH} (points only)`,
);
// The panel would otherwise keep the pre-load count and legend until the
// next natural refresh — up to the 5-minute catalog interval.
parts.controls._notifyRowControls();
parts.tracking._applyPendingTrackingRestore();
return { status: 'ready', added };
} catch (e) {
if (loadSignal.aborted || e?.name === 'AbortError') {
return {
status: 'cancelled',
reason: String(loadSignal.reason || 'aborted'),
};
}
console.warn('[Data:Satellites] Dense catalog load failed:', e);
_denseLoadFailed(token, 'feed unreachable');
return { status: 'source-unavailable', reason: 'feed unreachable' };
} finally {
if (layerState._denseLoadController === resourceController)
layerState._denseLoadController = null;
}
}
/**
* Settle a failed dense load: drop any partial chunk, return the layer to the
* core catalog, and leave the reason on the chip. Reverting the param is the
* point — a chip that reads ACTIVE over an empty sky is a lie.
* @param {number} token The load token that failed.
* @param {string} reason Short operator-facing cause.
*/
function _denseLoadFailed(token, reason) {
// A newer load (or a mode flip) already owns the state — say nothing.
if (token !== layerState._denseLoadToken) return;
layerState._params.catalog = 'core';
_removeDenseCatalog();
layerState._denseStatus = 'failed';
layerState._denseError = reason;
parts.controls._notifyRowControls();
}
/** Remove all dense extras (catalog entries, points, tracking if needed). */
function _removeDenseCatalog() {
layerState._denseLoadController?.abort();
layerState._denseLoadController = null;
layerState._denseLoadToken++; // cancel any in-flight dense load
if (
layerState._trackedNorad !== null &&
layerState._catalog.get(layerState._trackedNorad)?.group === 'dense'
) {
parts.tracking._clearTracking();
}
for (const noradId of layerState._denseIds) {
const point = layerState._points.get(noradId);
if (point && layerState._pointCollection)
layerState._pointCollection.remove(point);
layerState._points.delete(noradId);
layerState._catalog.delete(noradId);
}
layerState._denseIds = [];
layerState._denseStale = false;
layerState._denseCursor = 0;
layerState._count = layerState._points.size;
layerState._catalogRevision++;
}
return {
_abortActiveUpdates,
_propagateDenseChunk,
_loadDenseCatalog,
_denseLoadFailed,
_removeDenseCatalog,
};
}