mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
209 lines
8.3 KiB
JavaScript
209 lines
8.3 KiB
JavaScript
import { twoline2satrec } from 'satellite.js';
|
|
import * as Cesium from 'cesium';
|
|
import { msg } from '../../i18n/index.js';
|
|
import { noradIdFromSatnum } from '../../sources/tle.js';
|
|
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
|
|
|
|
export function createIngestion({
|
|
state: layerState,
|
|
services,
|
|
parts,
|
|
source,
|
|
}) {
|
|
const methods = {
|
|
async update(viewer, { signal = null } = {}) {
|
|
const trackingRefreshEpoch = ++layerState._trackingRefreshEpoch;
|
|
layerState._lastTrackingRefreshOutcome = {
|
|
epoch: trackingRefreshEpoch,
|
|
status: 'source-unavailable',
|
|
failedGroups: [],
|
|
};
|
|
const resourceController = new AbortController();
|
|
layerState._activeUpdateControllers.add(resourceController);
|
|
const updateSignal = signal
|
|
? AbortSignal.any([signal, resourceController.signal])
|
|
: resourceController.signal;
|
|
try {
|
|
updateSignal.throwIfAborted();
|
|
// Load all core groups in parallel; a failed/empty group degrades
|
|
// gracefully (parseTLE of an upstream error body yields []).
|
|
const results = await Promise.all(
|
|
CATALOG_GROUPS.map(async (groupDef) => {
|
|
try {
|
|
const res = await source.readGroup(groupDef.path, {
|
|
signal: updateSignal,
|
|
});
|
|
if (!res.ok) return { ...groupDef, entries: [], ok: false };
|
|
const entries = parts.orbits.parseTLE(res.text);
|
|
updateSignal.throwIfAborted();
|
|
return {
|
|
...groupDef,
|
|
entries,
|
|
ok: entries.length > 0,
|
|
stale: Boolean(res.stale),
|
|
};
|
|
} catch (error) {
|
|
if (updateSignal.aborted || error?.name === 'AbortError')
|
|
throw error;
|
|
return { ...groupDef, entries: [], ok: false };
|
|
}
|
|
}),
|
|
);
|
|
updateSignal.throwIfAborted();
|
|
|
|
const failed = results.filter((r) => !r.ok).map((r) => r.path);
|
|
if (failed.length > 0) {
|
|
console.warn(
|
|
`[Data:Satellites] Groups failed or empty: ${failed.join(', ')}`,
|
|
);
|
|
}
|
|
console.log(
|
|
`[Data:Satellites] Loaded ${results.map((r) => `${r.tag}:${r.entries.length}`).join(' ')}`,
|
|
);
|
|
|
|
// CelesTrak outage guard (H3): if EVERY group failed, bail BEFORE clearing.
|
|
// Wiping the collection + catalog here would blank all 838 satellites while
|
|
// the chip still read "just now". Keep the existing (stale) catalog on
|
|
// screen and surface the outage instead — do NOT stamp _lastUpdate.
|
|
if (results.every((r) => !r.ok)) {
|
|
// English data (stats reach the voice tools); the panel translates it.
|
|
layerState._lastError = msg('CelesTrak unreachable');
|
|
console.warn(
|
|
'[Data:Satellites] All CelesTrak groups failed — keeping existing catalog, surfacing outage',
|
|
);
|
|
// Re-apply dense mode is skipped (no fresh core catalog); tracking untouched.
|
|
return;
|
|
}
|
|
|
|
// At least one group loaded — proceed with a fresh rebuild, but keep the
|
|
// partial outage visible at the layer control instead of presenting the
|
|
// reduced catalog as a fully healthy refresh.
|
|
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
|
|
layerState._lastError = failed.length
|
|
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
|
|
: null;
|
|
|
|
// Clear existing
|
|
layerState._pointCollection.removeAll();
|
|
layerState._points.clear();
|
|
for (const path of layerState._orbitPaths.values())
|
|
viewer.scene.primitives.remove(path.primitive);
|
|
layerState._orbitPaths.clear();
|
|
layerState._catalog.clear();
|
|
// The detection overlay caches one record per satellite and stamps its
|
|
// id/class at creation only. A rebuild can re-tag a satellite (a failed
|
|
// group shifts which one wins dedupe), so the cache must go with the
|
|
// catalog or those labels stay stale for the life of the session.
|
|
layerState._detectionObjects.clear();
|
|
layerState._denseIds = [];
|
|
layerState._denseStale = false;
|
|
layerState._denseCursor = 0;
|
|
layerState._denseLoadController?.abort();
|
|
layerState._denseLoadController = null;
|
|
layerState._denseLoadToken++; // cancel any in-flight dense load against the old catalog
|
|
parts.labels._syncIssOverlay();
|
|
|
|
const now = new Date();
|
|
|
|
// Process all TLE entries in CATALOG_GROUPS order
|
|
const allEntries = [];
|
|
for (const r of results) {
|
|
for (const e of r.entries)
|
|
allEntries.push({ ...e, group: r.tag, stale: r.stale });
|
|
}
|
|
|
|
// Deduplicate by NORAD ID — first (most specific) group tag wins
|
|
const seen = new Set();
|
|
let coreStale = false;
|
|
|
|
for (const entry of allEntries) {
|
|
const satrec = twoline2satrec(entry.line1, entry.line2);
|
|
if (!satrec || satrec.error !== 0) continue;
|
|
|
|
const noradId = noradIdFromSatnum(satrec.satnum);
|
|
if (seen.has(noradId)) continue;
|
|
seen.add(noradId);
|
|
|
|
// Store in catalog
|
|
layerState._catalog.set(noradId, {
|
|
name: entry.name,
|
|
satrec,
|
|
group: entry.group,
|
|
});
|
|
|
|
coreStale ||= entry.stale;
|
|
|
|
// Propagate initial position
|
|
const pos = parts.orbits.propagatePosition(satrec, now);
|
|
if (!pos) continue;
|
|
|
|
const cartesian = Cesium.Cartesian3.fromDegrees(
|
|
pos.longitude,
|
|
pos.latitude,
|
|
pos.altitude,
|
|
);
|
|
|
|
// Add point primitive (styling from the shared table, WS-D3)
|
|
const style = parts.controls._pointStyleFor(noradId, entry.group);
|
|
const point = layerState._pointCollection.add({
|
|
position: cartesian,
|
|
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);
|
|
}
|
|
|
|
// Show ISS orbital path by default
|
|
if (layerState._catalog.has(ISS_NORAD)) {
|
|
parts.rendering._showOrbitPath(ISS_NORAD, POINT_STYLES.iss.color);
|
|
const issPath = layerState._orbitPaths.get(ISS_NORAD);
|
|
if (issPath) issPath.primitive.show = layerState._params.showOrbits;
|
|
|
|
parts.labels._syncIssOverlay();
|
|
}
|
|
|
|
layerState._coreStale = coreStale;
|
|
layerState._count = layerState._points.size;
|
|
layerState._catalogRevision++;
|
|
layerState._lastUpdate = Date.now();
|
|
layerState._lastPropagation = Date.now();
|
|
layerState._lastTrackingRefreshOutcome = {
|
|
epoch: trackingRefreshEpoch,
|
|
status: failed.length ? 'partial' : 'accepted',
|
|
failedGroups: [...failed],
|
|
};
|
|
console.log(
|
|
`[Data:Satellites] ${layerState._count} satellites active, ISS path shown`,
|
|
);
|
|
|
|
// Re-apply dense mode after a full catalog rebuild (fire-and-forget —
|
|
// _loadDenseCatalog handles its own errors and token invalidation).
|
|
layerState._denseLoadPromise =
|
|
layerState._params.catalog === 'dense'
|
|
? parts.catalog._loadDenseCatalog({ signal: updateSignal })
|
|
: Promise.resolve({ status: 'not-requested' });
|
|
// The catalog the published voice subject was resolved against is gone.
|
|
// Re-resolve it, or release the slot if the subject provably did not
|
|
// survive. Deliberately not awaited: it waits on dense settlement, and
|
|
// the rebuild must not block on that.
|
|
void parts.tracking._reconcileTrackedSubjectContext();
|
|
parts.tracking._applyPendingTrackingRestore();
|
|
} catch (e) {
|
|
if (updateSignal.aborted || e?.name === 'AbortError') {
|
|
throw new DOMException('Satellite update aborted', 'AbortError');
|
|
}
|
|
console.warn('[Data:Satellites] Fetch error:', e);
|
|
} finally {
|
|
layerState._activeUpdateControllers.delete(resourceController);
|
|
}
|
|
},
|
|
};
|
|
|
|
return { methods };
|
|
}
|