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 }; }