mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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.
243 lines
8.9 KiB
JavaScript
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,
|
|
};
|
|
}
|