refactor(space): separate satellite and mission layer components

This commit is contained in:
Sameh Khamis committed 2026-09-13 13:12:34 -07:00
1 parent 61db945b21
commit ce0568983a
44 files changed
+8767 -6095

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.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();
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();
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 = Number(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);
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._denseCursor = 0;
layerState._count = layerState._points.size;
layerState._catalogRevision++;
}
return {
_abortActiveUpdates,
_propagateDenseChunk,
_loadDenseCatalog,
_denseLoadFailed,
_removeDenseCatalog,
};
}
+562
View File
@@ -0,0 +1,562 @@
import {
tallySatelliteClasses,
satelliteClassLabel,
satelliteClassLegend,
} from '../../data/satelliteClass.js';
import * as Cesium from 'cesium';
import { ISS_NORAD, POINT_STYLES } from './policy.js';
export function createControls({ state: layerState, services, parts, source }) {
const { isExplicitLayerStateOrigin } = services.layerState;
/**
* Resolve the canonical point style for a satellite.
* @param {number} noradId NORAD catalog number.
* @param {string|undefined} group Catalog group tag (see CATALOG_GROUPS / 'dense').
* @returns {{ pixelSize: number, color: Cesium.Color, outlineColor: Cesium.Color, outlineWidth: number }}
*/
function _pointStyleFor(noradId, group) {
if (noradId === ISS_NORAD) return POINT_STYLES.iss;
return POINT_STYLES[group] || POINT_STYLES.visual;
}
/** Tell the manager to re-render this layer's row (chip state / legend counts). */
function _notifyRowControls() {
try {
layerState._rowControlsListener?.();
} catch (error) {
console.warn('[Data:Satellites] row-controls listener failed:', error);
}
}
/**
* Per-class tally for the row legend, cached against the catalog revision.
* Without the cache this scans ~10.7k entries in dense mode on every panel
* refresh, and any layer polling on the 1s stats timer refreshes the panel.
* @returns {Record<string, number>} Class key → count.
*/
function _classTally() {
if (
layerState._classTallyCache.counts &&
layerState._classTallyCache.revision === layerState._catalogRevision
) {
return layerState._classTallyCache.counts;
}
const entries = [];
// Pass the ISS flag, not just the group: during a stations-feed outage the
// ISS is ingested as `visual`, and the legend must file it exactly where its
// card does (STATION) rather than letting STATION vanish from the legend.
for (const [noradId, sat] of layerState._catalog) {
entries.push({ group: sat.group, isIss: noradId === ISS_NORAD });
}
const counts = tallySatelliteClasses(entries);
layerState._classTallyCache = {
revision: layerState._catalogRevision,
counts,
};
return counts;
}
/**
* Satellite preferences may be restored while the layer is disabled (for
* example, when Space Missions releases its dependency). Preferences must not
* revive render primitives until the layer is explicitly enabled again.
* @param {boolean} layerEnabled Whether the Satellite layer is enabled.
* @param {boolean} requestedVisible Whether the current presentation requests visibility.
* @returns {boolean} Whether a primitive should be visible now.
*/
function satelliteVisualsVisible(layerEnabled, requestedVisible) {
return Boolean(layerEnabled) && Boolean(requestedVisible);
}
/**
* Decide whether a valid catalog request represents a real mode transition.
* Reapplying an already-active mode must not restart the dense TLE load.
* @param {'core'|'dense'} currentCatalog Current catalog mode.
* @param {string|undefined} requestedCatalog Requested catalog mode.
* @returns {boolean} Whether the catalog mode should change.
*/
function satelliteCatalogModeChanged(currentCatalog, requestedCatalog) {
return (
(requestedCatalog === 'core' || requestedCatalog === 'dense') &&
requestedCatalog !== currentCatalog
);
}
const methods = {
id: 'satellites',
name: 'Satellites',
icon: '🛰️',
source: 'CelesTrak',
updateInterval: 0,
// We use preRender for real-time updates, not interval polling
refreshInterval: 5 * 60 * 1000,
getDetectableObjects(options = {}) {
if (!layerState._pointCollection || !layerState._pointCollection.show)
return [];
// Dense extras are points-only: excluded from the detection overlay.
const eligibleCount = Math.max(
1,
layerState._points.size - layerState._denseIds.length,
);
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: eligibleCount;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
const stride = Math.max(1, Math.ceil(eligibleCount / maxCount));
const start = seed % stride;
const result = [];
let idx = 0;
for (const [noradId, point] of layerState._points) {
if (
layerState._denseIds.length > 0 &&
layerState._catalog.get(noradId)?.group === 'dense'
)
continue;
const shouldTake = (idx - start) % stride === 0;
idx++;
if (!shouldTake) continue;
if (!point.position) continue;
const isTracked = noradId === layerState._trackedNorad;
// A docked companion sits at the tracked subject's own position, so its
// mark and label would stack underneath the tracked card. It is listed on
// that card instead. Only members of the tracked cluster are affected —
// unrelated nearby satellites are never suppressed.
if (!isTracked && layerState._dockedCompanions.has(noradId)) continue;
const cat = layerState._catalog.get(noradId);
let object = layerState._detectionObjects.get(noradId);
if (!object) {
object = {
sourceId: noradId,
id: cat?.name || `SAT-${noradId}`,
type: 'SAT',
// Human class ("NAV · GPS"), not the raw CelesTrak tag ("GPS-OPS").
// The detection canvas composites ABOVE the post-FX chain, so this
// is how class survives NVG/FLIR once the dot colors are collapsed.
klass: satelliteClassLabel(cat?.group, {
isIss: noradId === ISS_NORAD,
}),
};
layerState._detectionObjects.set(noradId, object);
}
object.position = point.position;
object.skipLabel = isTracked;
result.push(object);
if (result.length >= maxCount) break;
}
return result;
},
/**
* Find a satellite by exact NORAD id (numeric string) or case-insensitive
* name substring. Position is freshly propagated via SGP4.
* @param {string|number} query NORAD id or partial name.
* @returns {{ noradId: number, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }|null}
*/
findByQuery(query) {
if (
query === null ||
query === undefined ||
!layerState._catalog ||
layerState._catalog.size === 0
)
return null;
const q = String(query).trim();
if (!q) return null;
let noradId = null;
if (/^\d+$/.test(q) && layerState._catalog.has(Number(q))) {
noradId = Number(q);
} else {
const lower = q.toLowerCase();
for (const [id, sat] of layerState._catalog) {
if (sat.name.toLowerCase().includes(lower)) {
noradId = id;
break;
}
}
}
if (noradId === null) return null;
const sat = layerState._catalog.get(noradId);
const pos = parts.orbits.propagatePosition(sat.satrec, new Date());
if (!pos) return null;
return {
noradId,
name: sat.name.trim(),
position: Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
),
latitude: pos.latitude,
longitude: pos.longitude,
altitudeM: pos.altitude,
};
},
/**
* Get positions of currently rendered satellites from per-point state
* (no SGP4 re-propagation).
* @param {number} [maxCount=300] Maximum entries to return.
* @returns {Array<{ id: number, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }>}
*/
getAllPositions(maxCount = 300) {
const result = [];
if (!layerState._points || layerState._points.size === 0) return result;
const cap =
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 300;
for (const [noradId, point] of layerState._points) {
if (result.length >= cap) break;
if (!point.position) continue;
// Dense extras are points-only — keep voice/framing lists to the core catalog.
if (
layerState._denseIds.length > 0 &&
layerState._catalog.get(noradId)?.group === 'dense'
)
continue;
const carto = Cesium.Cartographic.fromCartesian(point.position);
if (!carto) continue;
const sat = layerState._catalog.get(noradId);
result.push({
id: noradId,
label: sat ? sat.name.trim() : String(noradId),
position: point.position,
latitude: Cesium.Math.toDegrees(carto.latitude),
longitude: Cesium.Math.toDegrees(carto.longitude),
altitudeM: carto.height,
});
}
return result;
},
/**
* Track a satellite by NORAD id (camera follow + orbit path + highlight),
* same path as clicking its point.
* @param {string|number} noradId NORAD catalog number.
* @returns {boolean} True if tracking started.
*/
trackById(noradId, { origin = 'programmatic' } = {}) {
const id = Number(noradId);
if (
!Number.isFinite(id) ||
!layerState._viewer ||
!layerState._catalog.has(id) ||
!layerState._points.has(id)
)
return false;
parts.tracking._cancelPendingTrackingRestore();
parts.tracking._trackSatellite(id, { origin });
return layerState._trackedNorad === id;
},
/**
* Resolve a shared Follow target only after the applicable CelesTrak
* catalog has settled. A partial catalog can prove presence, never absence.
*/
async resolveTrackingRestoreTarget(
noradId,
{ signal = null, origin = 'share-restore' } = {},
) {
if (signal?.aborted)
return {
status: 'cancelled',
reason: String(signal.reason || 'aborted'),
};
const id = parts.tracking._normalizeTrackedNorad(noradId);
if (id === null) return { status: 'missing', reason: 'invalid-target' };
const outcome = layerState._lastTrackingRefreshOutcome;
const found = () =>
layerState._catalog.has(id) && layerState._points.has(id);
const follow = () => {
if (signal?.aborted)
return {
status: 'cancelled',
reason: String(signal.reason || 'aborted'),
};
return this.trackById(id, { origin })
? { status: 'found', refreshEpoch: outcome.epoch }
: {
status: 'source-unavailable',
reason: 'target-not-renderable',
refreshEpoch: outcome.epoch,
};
};
if (found()) return follow();
if (outcome.status !== 'accepted' && outcome.status !== 'partial') {
return {
status: 'source-unavailable',
reason: 'CelesTrak catalog unavailable',
refreshEpoch: outcome.epoch,
};
}
if (layerState._params.catalog === 'dense') {
const dense = await (layerState._denseLoadPromise ||
Promise.resolve({ status: 'source-unavailable' }));
if (signal?.aborted)
return {
status: 'cancelled',
reason: String(signal.reason || 'aborted'),
};
if (layerState._lastTrackingRefreshOutcome.epoch !== outcome.epoch) {
return { status: 'superseded', reason: 'newer-catalog-refresh' };
}
if (found()) return follow();
if (dense?.status !== 'ready') {
return {
status: 'source-unavailable',
reason: dense?.reason || 'dense catalog unavailable',
refreshEpoch: outcome.epoch,
};
}
}
if (outcome.status === 'partial') {
return {
status: 'source-unavailable',
reason: 'partial CelesTrak catalog cannot prove absence',
refreshEpoch: outcome.epoch,
failedGroups: [...outcome.failedGroups],
};
}
return {
status: 'missing',
reason: 'target-absent-from-catalog',
refreshEpoch: outcome.epoch,
};
},
/**
* Stop tracking the currently tracked satellite (no-op if none).
* @returns {boolean} Always true.
*/
stopTracking({ origin = 'programmatic' } = {}) {
parts.tracking._cancelPendingTrackingRestore();
parts.tracking._clearTracking(false, { origin });
return true;
},
cancelPendingTrackingRestore() {
parts.tracking._cancelPendingTrackingRestore();
},
/**
* Get info about the currently tracked satellite.
* @returns {{ noradId: number, name: string, latitude: number, longitude: number, altitudeM: number }|null}
*/
getTrackedInfo() {
if (
layerState._trackedNorad === null ||
!layerState._catalog.has(layerState._trackedNorad)
)
return null;
const sat = layerState._catalog.get(layerState._trackedNorad);
// Per-frame cache (WS-D2) — same epoch as the dot/label/camera this frame.
const pos = parts.tracking._getTrackedFramePosition();
if (!pos) return null;
return {
noradId: layerState._trackedNorad,
name: sat.name.trim(),
latitude: pos.latitude,
longitude: pos.longitude,
altitudeM: pos.altitude,
};
},
/**
* Runtime params (DataLayerManager.setLayerParams path).
* catalog: 'core' (default, ~840 sats) | 'dense' (adds the Starlink shell
* as points-only extras on a relaxed propagation budget).
* @param {{ catalog?: 'core'|'dense', showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params]
*/
setParams(params = {}, { origin = 'programmatic' } = {}) {
if (
isExplicitLayerStateOrigin(origin) &&
!Object.hasOwn(params, 'selectedSatTrackingId')
) {
parts.tracking._cancelPendingTrackingRestore();
}
const catalog = params.catalog;
if (catalog !== undefined && catalog !== 'core' && catalog !== 'dense')
return false;
const catalogChanged = satelliteCatalogModeChanged(
layerState._params.catalog,
catalog,
);
if (catalogChanged) {
layerState._params.catalog = catalog;
}
if (params.showPoints !== undefined) {
layerState._params.showPoints = params.showPoints !== false;
if (layerState._pointCollection)
layerState._pointCollection.show = satelliteVisualsVisible(
layerState._enabled,
layerState._params.showPoints,
);
}
if (params.showOrbits !== undefined) {
layerState._params.showOrbits = params.showOrbits !== false;
for (const path of layerState._orbitPaths.values())
path.primitive.show = satelliteVisualsVisible(
layerState._enabled,
layerState._params.showOrbits,
);
parts.labels._syncIssOverlay();
}
if (catalogChanged && catalog === 'dense') {
layerState._denseLoadPromise = parts.catalog._loadDenseCatalog();
} else if (catalog === 'core') {
if (catalogChanged) parts.catalog._removeDenseCatalog();
// Any explicit request for core clears the error, even when the mode did
// NOT change: a failed dense load already reverted the param to core, so
// a Space Missions restore of an already-core snapshot would otherwise
// leave the user staring at a DENSE ✕ they never caused.
layerState._denseStatus = 'idle';
layerState._denseError = null;
}
if (catalogChanged)
console.log(`[Data:Satellites] Catalog mode: ${catalog}`);
if (Object.hasOwn(params, 'selectedSatTrackingId')) {
const requested = parts.tracking._normalizeTrackedNorad(
params.selectedSatTrackingId,
);
if (requested === layerState._trackedNorad) {
layerState._pendingTrackingRestore = null;
} else if (requested === null) {
parts.tracking._cancelPendingTrackingRestore();
if (layerState._trackedNorad !== null)
parts.tracking._clearTracking(false, { origin });
} else {
const generation = ++layerState._trackingIntentGeneration;
layerState._pendingTrackingRestore = {
id: requested,
generation,
origin,
};
if (layerState._trackedNorad !== null)
parts.tracking._clearTracking(false, { origin });
parts.tracking._applyPendingTrackingRestore();
}
}
return true;
},
/** @returns {{ catalog: string }} Current runtime params. */
getParams() {
return {
catalog: layerState._params.catalog,
showPoints: layerState._params.showPoints,
showOrbits: layerState._params.showOrbits,
selectedSatTrackingId: layerState._trackedNorad,
};
},
/**
* Layer-row sub-controls (DataLayerManager row-controls contract): the DENSE
* catalog chip plus a class legend so the point colors are learnable without
* a new panel.
*
* The chip is stateless — it declares the params to apply and the manager
* owns the write, so the Space Missions snapshot/restore path (which drives
* the same `catalog` param) stays the single source of truth and the chip
* always renders whatever the layer actually has.
*
* The chip reports the dense LOAD state, not the catalog param: the param
* flips synchronously while the Starlink shell takes seconds to arrive and
* frequently 502s, so ACTIVE means "dense points are on screen" and nothing
* less. The legend tally is cached against the catalog revision.
* @returns {{ chips: Array<object>, legend: Array<object> }} Row controls.
*/
getRowControls() {
// A dependency owner (Space Missions) borrows this layer for TLE lookup
// with showPoints:false. Nothing is rendered, so a legend would describe an
// empty sky and a chip write would be silently reverted by that owner's
// restore. Surrender the row rather than lie about it.
if (!layerState._params.showPoints) return { chips: [], legend: [] };
const loading = layerState._denseStatus === 'loading';
const failed = layerState._denseStatus === 'failed';
const active =
layerState._params.catalog === 'dense' &&
layerState._denseStatus === 'ready';
let title =
'Add the full Starlink broadband shell (thousands of extra points)';
if (loading) title = 'Loading the Starlink shell…';
else if (failed)
title = `Starlink ${layerState._denseError || 'load failed'} — click to retry`;
else if (active)
title =
'Showing the full Starlink shell — click for the core catalog only';
return {
chips: [
{
id: 'catalog',
label: loading ? 'DENSE ···' : failed ? 'DENSE ✕' : 'DENSE',
active,
busy: loading,
disabled: loading,
state: loading
? 'loading'
: failed
? 'error'
: active
? 'active'
: 'idle',
title,
params: { catalog: active ? 'core' : 'dense' },
},
],
legend: satelliteClassLegend(_classTally()),
};
},
/**
* Install the manager's "row controls changed" callback. The dense load is
* asynchronous, so completion and failure have to push a re-render — nothing
* else would repaint this row before the 5-minute catalog refresh.
* @param {(() => void)|null} listener Callback, or null to detach.
*/
setRowControlsListener(listener) {
layerState._rowControlsListener =
typeof listener === 'function' ? listener : null;
},
getStats() {
return {
count: layerState._count,
lastUpdate: layerState._lastUpdate,
stale: false,
status:
layerState._lastError === 'CelesTrak unreachable'
? 'unavailable'
: layerState._lastError
? 'degraded'
: 'nominal',
error: layerState._lastError,
};
},
};
return {
_pointStyleFor,
_notifyRowControls,
_classTally,
satelliteVisualsVisible,
satelliteCatalogModeChanged,
methods,
};
}
+73
View File
@@ -0,0 +1,73 @@
import { createControls } from './controls.js';
import { createCatalog } from './catalog.js';
import { createLabels } from './labels.js';
import { createOrbits } from './orbits.js';
import { createRendering } from './rendering.js';
import { createTracking } from './tracking.js';
import { createTesting } from './testing.js';
import { createInteraction } from './interaction.js';
import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
export function createSatellitesLayer({ services, source }) {
if (typeof source?.readGroup !== 'function')
throw new TypeError('A satellites source is required');
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.controls = createControls(context);
parts.catalog = createCatalog(context);
parts.labels = createLabels(context);
parts.orbits = createOrbits(context);
parts.rendering = createRendering(context);
parts.tracking = createTracking(context);
parts.testing = createTesting(context);
parts.interaction = createInteraction(context);
parts.lifecycle = createLifecycle(context);
parts.ingestion = createIngestion(context);
return Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
{
satelliteVisualsVisible: parts.controls.satelliteVisualsVisible,
satelliteCatalogModeChanged: parts.controls.satelliteCatalogModeChanged,
createIssOverlayEntry: parts.labels.createIssOverlayEntry,
orbitFrameModelMatrix: parts.orbits.orbitFrameModelMatrix,
_setTrackedSatelliteRefreshStateForTest:
parts.testing._setTrackedSatelliteRefreshStateForTest,
_setSatelliteTrackingRefreshOutcomeForTest:
parts.testing._setSatelliteTrackingRefreshOutcomeForTest,
_trackedFrameCartesianForTest:
parts.testing._trackedFrameCartesianForTest,
_runSatellitePreRenderForTest:
parts.testing._runSatellitePreRenderForTest,
_setDenseCatalogStateForTest: parts.testing._setDenseCatalogStateForTest,
_clearDenseCatalogStateForTest:
parts.testing._clearDenseCatalogStateForTest,
_catalogGroupForTest: parts.testing._catalogGroupForTest,
_setSatelliteLabelLifecycleStateForTest:
parts.testing._setSatelliteLabelLifecycleStateForTest,
_trackIssForTest: parts.testing._trackIssForTest,
_pendingSatelliteTrackingRestoreForTest:
parts.testing._pendingSatelliteTrackingRestoreForTest,
_applyPendingSatelliteTrackingRestoreForTest:
parts.testing._applyPendingSatelliteTrackingRestoreForTest,
_removeSatelliteTrackingCandidateForTest:
parts.testing._removeSatelliteTrackingCandidateForTest,
_clearSatelliteLabelLifecycleForTest:
parts.testing._clearSatelliteLabelLifecycleForTest,
applySatellitePointFocusDeemphasis:
parts.rendering.applySatellitePointFocusDeemphasis,
getNextIssPass: parts.orbits.getNextIssPass,
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
},
);
}
export { ISS_OVERLAY_SOURCE_ID, ISS_OVERLAY_SOURCE_OPTIONS } from './policy.js';
export { createSatelliteSource } from './source.js';
+193
View File
@@ -0,0 +1,193 @@
import { twoline2satrec } from 'satellite.js';
import * as Cesium from 'cesium';
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 };
} 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)) {
layerState._lastError = '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.
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._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 });
}
// Deduplicate by NORAD ID — first (most specific) group tag wins
const seen = new Set();
for (const entry of allEntries) {
const satrec = twoline2satrec(entry.line1, entry.line2);
if (!satrec || satrec.error !== 0) continue;
const noradId = Number(satrec.satnum);
if (seen.has(noradId)) continue;
seen.add(noradId);
// Store in catalog
layerState._catalog.set(noradId, {
name: entry.name,
satrec,
group: entry.group,
});
// 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._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 };
}
+86
View File
@@ -0,0 +1,86 @@
import * as Cesium from 'cesium';
export function createInteraction({
state: layerState,
services,
parts,
source,
}) {
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
function _onKeyDown(e) {
if (layerState._enabled && e.key === 'Escape' && layerState._trackedNorad) {
parts.tracking._cancelPendingTrackingRestore();
parts.tracking._clearTracking(false, { origin: 'user' });
}
}
function _installClickHandler(viewer) {
if (layerState._clickHandler) return; // already installed
// Cross-layer untrack (H2, mirror of flights): if ANOTHER layer (flights,
// military, …) grabs the follow-camera, drop our tracking so the orbit ring /
// tracked entity don't orphan — without touching viewer.trackedEntity (the
// new owner controls it). Guarded so our OWN switch (viewer.trackedEntity
// briefly undefined mid-_trackSatellite) doesn't self-clear.
if (!layerState._trackedEntityChangedRemove) {
layerState._trackedEntityChangedRemove =
viewer.trackedEntityChanged.addEventListener(() => {
if (!layerState._enabled) return;
if (
layerState._trackedNorad &&
layerState._viewer &&
layerState._viewer.trackedEntity &&
layerState._viewer.trackedEntity !== layerState._trackedEntity
) {
parts.tracking._clearTracking(true, {
origin:
layerState._viewer.trackedEntity?.gevSelectionOrigin ||
'programmatic',
});
}
});
}
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
layerState._clickHandler.setInputAction((click) => {
if (!layerState._enabled) return;
const picked = viewer.scene.pick(click.position);
if (picked) {
// Clicking tracked entity itself — ignore
if (picked.id === layerState._trackedEntity) return;
// Check if it's a satellite point (id is NORAD catalog number)
const prim = picked.primitive;
if (prim && prim.id != null) {
const noradId = Number(prim.id);
if (!isNaN(noradId) && layerState._catalog.has(noradId)) {
parts.tracking._cancelPendingTrackingRestore();
parts.tracking._trackSatellite(noradId, { origin: 'user' });
return;
}
}
}
// A pick that belongs to a sibling layer (plane, vessel, station, CCTV
// camera…) is not "empty space" — leave OUR tracking (and crucially
// viewer.trackedEntity, which that sibling may have JUST set) alone (H2).
if (picked) {
const pickedId = resolvePickId(picked);
if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return;
}
// Clicked empty space — deselect
if (layerState._trackedNorad) {
parts.tracking._cancelPendingTrackingRestore();
parts.tracking._clearTracking(false, { origin: 'user' });
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
document.addEventListener('keydown', _onKeyDown);
}
return { _onKeyDown, _installClickHandler };
}
+125
View File
@@ -0,0 +1,125 @@
import {
ISS_NORAD,
DOCKED_SCAN_INTERVAL_MS,
DOCKED_COMPANION_RADIUS_M,
ISS_OVERLAY_SOURCE_ID,
ISS_OVERLAY_SOURCE_OPTIONS,
} from './policy.js';
export function createLabels({ state: layerState, services, parts, source }) {
/** Build the persistent ISS ambient label from the cached point position. */
function createIssOverlayEntry(position) {
return {
id: String(ISS_NORAD),
position,
variant: 'label',
title: 'ISS',
accent: '#ff4444',
priority: 1000,
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
distanceScale: {
near: 1_000_000,
nearValue: 1,
far: 30_000_000,
farValue: 0.4,
},
gapPx: 14,
verticalOnly: true,
placement: 'above',
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
};
}
/** Cached ISS point only; never performs a fresh SGP4 propagation. */
function _issDisplayCached() {
return layerState._points.get(ISS_NORAD)?.position || null;
}
/**
* Rebuild the docked-companion set for the tracked satellite.
* @returns {boolean} true when membership changed (callers resync presentation).
*/
function _refreshDockedCompanions(nowMs) {
const trackedPosition =
layerState._trackedNorad === null
? null
: parts.tracking._trackedDisplayCached();
if (!trackedPosition) {
if (layerState._dockedCompanions.size === 0) return false;
layerState._dockedCompanions = new Set();
return true;
}
if (nowMs - layerState._lastDockedScanMs < DOCKED_SCAN_INTERVAL_MS)
return false;
layerState._lastDockedScanMs = nowMs;
const radiusSq = DOCKED_COMPANION_RADIUS_M * DOCKED_COMPANION_RADIUS_M;
let changed = false;
let found = 0;
const next = new Set();
for (const [noradId, point] of layerState._points) {
if (noradId === layerState._trackedNorad || !point?.position) continue;
const dx = point.position.x - trackedPosition.x;
const dy = point.position.y - trackedPosition.y;
const dz = point.position.z - trackedPosition.z;
if (dx * dx + dy * dy + dz * dz > radiusSq) continue;
next.add(noradId);
found++;
if (!layerState._dockedCompanions.has(noradId)) changed = true;
}
if (found !== layerState._dockedCompanions.size) changed = true;
if (changed) layerState._dockedCompanions = next;
return changed;
}
/** Names of the docked companions, stable-sorted so the card text never churns. */
function _dockedCompanionNames() {
const names = [];
for (const noradId of layerState._dockedCompanions) {
names.push(
layerState._catalog.get(noradId)?.name?.trim() || `SAT-${noradId}`,
);
}
return names.sort();
}
/** Keep persistent ISS text mutually exclusive with the tracked host card. */
function _syncIssOverlay() {
// Hidden when ISS is the tracked subject, and equally when ISS is DOCKED to
// whatever is tracked: its ambient label would otherwise sit underneath the
// tracked card at the same position.
const visible =
layerState._enabled &&
layerState._params.showOrbits &&
layerState._trackedNorad !== ISS_NORAD &&
!layerState._dockedCompanions.has(ISS_NORAD) &&
layerState._catalog.has(ISS_NORAD) &&
_issDisplayCached();
if (!visible) {
layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID);
layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false);
return;
}
layerState._overlayHost.setEntries(
ISS_OVERLAY_SOURCE_ID,
[createIssOverlayEntry(_issDisplayCached)],
ISS_OVERLAY_SOURCE_OPTIONS,
);
layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, true);
}
return {
createIssOverlayEntry,
_issDisplayCached,
_refreshDockedCompanions,
_dockedCompanionNames,
_syncIssOverlay,
};
}
+179
View File
@@ -0,0 +1,179 @@
import * as Cesium from 'cesium';
import { ISS_OVERLAY_SOURCE_ID } from './policy.js';
export function createLifecycle({
state: layerState,
services,
parts,
source,
}) {
const { clearFocusTarget } = services.focus;
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { registerPickOwner, unregisterPickOwner } = services.picking;
const methods = {
// Catalog data refresh; propagation remains preRender-owned.
async init(viewer) {
parts.catalog._abortActiveUpdates();
clearFocusTarget('satellites');
layerState._viewer = viewer;
layerState._catalog = new Map();
layerState._points = new Map();
layerState._detectionObjects = new Map();
layerState._orbitPaths = new Map();
layerState._count = 0;
layerState._lastUpdate = null;
layerState._trackedNorad = null;
parts.tracking._cancelPendingTrackingRestore();
layerState._trackedEntity = null;
layerState._trackedFrameNumber = -1;
layerState._trackedFrameGeo = null;
layerState._trackedFrameNowForTest = null;
layerState._lastFocusUpdate = 0;
layerState._activeFocusCount = 0;
layerState._enabled = false;
layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID);
layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false);
// Dense extras rebuild via update() when _params.catalog === 'dense'
// (the catalog-mode preference itself is sticky across init/destroy).
layerState._denseIds = [];
layerState._denseCursor = 0;
layerState._denseLoadPromise = null;
layerState._denseLoadToken++;
layerState._denseStatus = 'idle';
layerState._denseError = null;
layerState._catalogRevision++;
// Point primitives for satellite dots
layerState._pointCollection = new Cesium.PointPrimitiveCollection();
viewer.scene.primitives.add(layerState._pointCollection);
parts.interaction._installClickHandler(viewer);
// Pre-render listener for real-time position updates
// (fleet propagation + tracked per-frame dot + orbit ring GMST rotation)
layerState._preRenderListener = viewer.scene.preRender.addEventListener(
parts.rendering._preRenderTick,
);
console.log('[Data:Satellites] Initialized');
},
enable(viewer) {
layerState._enabled = true;
holdContinuousRender('satellites'); // per-frame animator (perf wave 2)
if (layerState._pointCollection)
layerState._pointCollection.show =
parts.controls.satelliteVisualsVisible(
layerState._enabled,
layerState._params.showPoints,
);
// Orbit ring primitives + persistent ISS host label — show them
for (const path of layerState._orbitPaths.values())
path.primitive.show = parts.controls.satelliteVisualsVisible(
layerState._enabled,
layerState._params.showOrbits,
);
parts.labels._syncIssOverlay();
// Re-attach input handlers and preRender propagation
parts.interaction._installClickHandler(viewer);
// Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers;
// the registry hands predicates String()-coerced ids, so match via Number().
registerPickOwner('satellites', (pickedId) => {
const norad = Number(pickedId);
return Number.isFinite(norad) && layerState._points.has(norad);
});
if (!layerState._preRenderListener && viewer) {
layerState._preRenderListener = viewer.scene.preRender.addEventListener(
parts.rendering._preRenderTick,
);
}
parts.tracking._applyPendingTrackingRestore();
},
disable(viewer) {
parts.catalog._abortActiveUpdates();
parts.tracking._cancelPendingTrackingRestore();
layerState._enabled = false;
releaseContinuousRender('satellites');
if (layerState._pointCollection) layerState._pointCollection.show = false;
for (const path of layerState._orbitPaths.values())
path.primitive.show = false;
parts.tracking._clearTracking();
parts.labels._syncIssOverlay();
// Remove click handler + keydown listener + preRender propagation while disabled
if (layerState._clickHandler) {
layerState._clickHandler.destroy();
layerState._clickHandler = null;
}
if (layerState._trackedEntityChangedRemove) {
layerState._trackedEntityChangedRemove();
layerState._trackedEntityChangedRemove = null;
}
document.removeEventListener('keydown', parts.interaction._onKeyDown);
unregisterPickOwner('satellites');
if (layerState._preRenderListener) {
layerState._preRenderListener();
layerState._preRenderListener = null;
}
},
destroy(viewer) {
parts.catalog._abortActiveUpdates();
releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix)
layerState._enabled = false;
parts.tracking._clearTracking();
parts.tracking._cancelPendingTrackingRestore();
if (layerState._clickHandler) {
layerState._clickHandler.destroy();
layerState._clickHandler = null;
}
if (layerState._trackedEntityChangedRemove) {
layerState._trackedEntityChangedRemove();
layerState._trackedEntityChangedRemove = null;
}
document.removeEventListener('keydown', parts.interaction._onKeyDown);
unregisterPickOwner('satellites');
if (layerState._preRenderListener) {
layerState._preRenderListener();
layerState._preRenderListener = null;
}
layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID);
layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false);
if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
}
// Orbit ring primitives are removed (and destroyed) here
for (const path of layerState._orbitPaths.values()) {
viewer.scene.primitives.remove(path.primitive);
}
layerState._points.clear();
layerState._detectionObjects.clear();
layerState._orbitPaths.clear();
layerState._catalog.clear();
layerState._denseIds = [];
layerState._denseCursor = 0;
layerState._denseLoadToken++;
layerState._denseStatus = 'idle';
layerState._denseError = null;
layerState._catalogRevision++;
layerState._rowControlsListener = null;
layerState._count = 0;
layerState._lastUpdate = null;
layerState._lastError = null;
layerState._lastFocusUpdate = 0;
layerState._activeFocusCount = 0;
layerState._trackingRefreshEpoch += 1;
layerState._lastTrackingRefreshOutcome = {
epoch: layerState._trackingRefreshEpoch,
status: 'destroyed',
failedGroups: [],
};
layerState._viewer = null;
},
};
return { methods };
}
+327
View File
@@ -0,0 +1,327 @@
import * as Cesium from 'cesium';
import {
gstime,
propagate,
eciToGeodetic,
degreesLong,
degreesLat,
twoline2satrec,
} from 'satellite.js';
import { findNextIssPass } from '../../data/issPass.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
/**
* Build the rigid ECEF transform that keeps an orbit path baked at one GMST
* aligned with live SGP4 positions propagated at another epoch.
* @param {number} gmstAtBake GMST used when the path positions were baked.
* @param {Date} nowDate Epoch whose rotating-Earth frame should be displayed.
* @param {Cesium.Matrix4} [result] Optional matrix to update in place.
* @returns {Cesium.Matrix4} Z-rotation from bake-time ECEF to current ECEF.
*/
function orbitFrameModelMatrix(
gmstAtBake,
nowDate,
result = new Cesium.Matrix4(),
) {
const deltaGmst = gstime(nowDate) - gmstAtBake;
const rotation = Cesium.Matrix3.fromRotationZ(
-deltaGmst,
layerState._scratchRingRotation,
);
return Cesium.Matrix4.fromRotationTranslation(
rotation,
Cesium.Cartesian3.ZERO,
result,
);
}
/**
* Parse TLE text into array of { name, line1, line2 } objects.
*/
function parseTLE(text) {
const lines = text
.trim()
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
const result = [];
for (let i = 0; i < lines.length - 2; i += 3) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
result.push({ name, line1, line2 });
}
}
return result;
}
/**
* Propagate satellite position at a given JS Date.
* Returns geodetic position plus inertial speed from the same SGP4 propagation
* epoch, or null on error.
*/
function propagatePosition(satrec, date) {
try {
const posVel = propagate(satrec, date);
if (!posVel.position || typeof posVel.position === 'boolean') return null;
const gmst = gstime(date);
const geo = eciToGeodetic(posVel.position, gmst);
const velocity =
posVel.velocity && typeof posVel.velocity !== 'boolean'
? posVel.velocity
: null;
const speedMps = velocity
? Math.hypot(velocity.x, velocity.y, velocity.z) * 1000
: null;
return {
longitude: degreesLong(geo.longitude),
latitude: degreesLat(geo.latitude),
altitude: geo.height * 1000, // km → meters
speedMps: Number.isFinite(speedMps) ? speedMps : null,
};
} catch {
return null;
}
}
function orbitalPeriodSeconds(satrec) {
const meanMotion = satrec.no * (1440 / (2 * Math.PI));
return 86400 / Math.max(meanMotion, 0.1);
}
/**
* Compute full orbital path as array of Cartesian3 positions.
* Steps around one full orbit based on the satellite's mean motion.
*/
function computeOrbitPath(satrec, referenceDate) {
const periodSec = orbitalPeriodSeconds(satrec);
const stepSec = periodSec / ORBIT_PATH_STEPS;
const positions = [];
const baseTime = referenceDate.getTime();
// Fix GMST to reference time so the orbital ring closes.
// Without this, Earth rotation during the orbit period (~24° for LEO)
// shifts the end point west of the start, leaving a visible gap.
const fixedGmst = gstime(referenceDate);
for (let i = 0; i <= ORBIT_PATH_STEPS; i++) {
const t = new Date(baseTime + i * stepSec * 1000);
try {
const posVel = propagate(satrec, t);
if (!posVel.position || typeof posVel.position === 'boolean') continue;
const geo = eciToGeodetic(posVel.position, fixedGmst);
positions.push(
Cesium.Cartesian3.fromDegrees(
degreesLong(geo.longitude),
degreesLat(geo.latitude),
geo.height * 1000,
),
);
} catch {
continue;
}
}
return positions;
}
/**
* Next ISS pass for an observer. Requires the catalog to have loaded (the
* satellites layer enabled at least once this session).
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}}
*/
function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) {
const sat = layerState._catalog.get(ISS_NORAD);
if (!sat || !sat.satrec) return { status: 'no-tle' };
const pass = findNextIssPass({
satrec: sat.satrec,
latDeg,
lonDeg,
fromMs: Date.now(),
minElevDeg,
});
return pass ? { status: 'ok', pass } : { status: 'none' };
}
/**
* Score a mission-to-catalog name match. Compact identifier containment handles
* names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as
* "Starlink Group 17-40" → an arbitrary "STARLINK-1008" remain below the
* acceptance threshold.
* @param {string} query Mission or payload name.
* @param {string} catalogName Satellite catalog name.
* @returns {number} Match score; 0 means no useful relationship.
*/
function scoreSatelliteNameMatch(query, catalogName) {
const q = String(query || '')
.trim()
.toLowerCase();
const name = String(catalogName || '')
.trim()
.toLowerCase();
if (!q || !name) return 0;
const qCompact = q.replace(/[^a-z0-9]/g, '');
const nameCompact = name.replace(/[^a-z0-9]/g, '');
if (qCompact === nameCompact) return 1000;
let score = 0;
if (
Math.min(qCompact.length, nameCompact.length) >= 5 &&
(qCompact.includes(nameCompact) || nameCompact.includes(qCompact))
) {
score += 200 + Math.min(qCompact.length, nameCompact.length);
}
const ignored = new Set([
'group',
'block',
'mission',
'launch',
'falcon',
'rocket',
]);
const tokens = q
.split(/[^a-z0-9]+/)
.filter((token) => token.length >= 4 && !ignored.has(token));
score += tokens.reduce(
(total, token) => total + (name.includes(token) ? token.length : 0),
0,
);
return score;
}
function internationalDesignatorYear(satrec) {
const match = String(satrec?.intldesg || '').match(/^(\d{2})/);
if (!match) return null;
const shortYear = Number(match[1]);
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function lookupTleEntries(tleText) {
if (tleText !== layerState._lookupTleText) {
layerState._lookupTleText = tleText;
layerState._lookupTleEntries = parseTLE(tleText);
}
return layerState._lookupTleEntries;
}
function tleLineLaunchYear(line1) {
const shortYear = Number(String(line1 || '').slice(9, 11));
if (!Number.isFinite(shortYear)) return null;
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function orbitTrackFromRecord(name, satrec) {
const referenceDate = new Date();
const current = propagatePosition(satrec, referenceDate);
if (!current) return null;
return {
noradId: Number(satrec.satnum),
name: String(name || '').trim(),
current,
periodSec: orbitalPeriodSeconds(satrec),
orbitPath: computeOrbitPath(satrec, referenceDate),
gmstAtBake: gstime(referenceDate),
positionAt: (date) => propagatePosition(satrec, date),
};
}
/**
* Find and propagate a mission payload directly from a TLE catalog.
* This supports newly launched payloads that are present in CelesTrak's active
* feed but have not yet moved into a narrower operational group.
* @param {string} tleText Three-line-element catalog text.
* @param {string} query Mission or payload name.
* @param {{launchTime?: string|null}} [options] Optional launch epoch for namesake rejection.
* @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null}
*/
function findSatelliteOrbitTrackInTle(tleText, query, options = {}) {
const launchYear = Number.isFinite(Date.parse(options.launchTime))
? new Date(options.launchTime).getUTCFullYear()
: null;
let bestEntry = null;
let bestScore = 0;
const catalogText = String(tleText || '');
for (const entry of lookupTleEntries(catalogText)) {
const designatorYear = tleLineLaunchYear(entry.line1);
if (
launchYear !== null &&
designatorYear !== null &&
designatorYear !== launchYear
)
continue;
const score = scoreSatelliteNameMatch(query, entry.name);
if (score > bestScore) {
bestEntry = entry;
bestScore = score;
}
}
if (!bestEntry || bestScore < 12) return null;
const satrec = twoline2satrec(bestEntry.line1, bestEntry.line2);
if (!satrec || satrec.error !== 0) return null;
return orbitTrackFromRecord(bestEntry.name, satrec);
}
/**
* Return the current propagated position and one-orbit path for a catalog satellite.
* @param {string|number} query NORAD id or mission/payload name.
* @param {{launchTime?: string|null}} [options] Optional launch epoch used to reject namesakes from another launch year.
* @returns {{noradId:number,name:string,current:object,periodSec:number,orbitPath:Cesium.Cartesian3[],positionAt:function(Date):object|null}|null}
*/
function getSatelliteOrbitTrack(query, options = {}) {
if (query === null || query === undefined || !layerState._catalog?.size)
return null;
const q = String(query).trim().toLowerCase();
let noradId = /^\d+$/.test(q) ? Number(q) : null;
if (noradId === null || !layerState._catalog.has(noradId)) {
noradId = null;
const launchYear = Number.isFinite(Date.parse(options.launchTime))
? new Date(options.launchTime).getUTCFullYear()
: null;
let bestScore = 0;
for (const [id, sat] of layerState._catalog) {
const designatorYear = internationalDesignatorYear(sat.satrec);
if (
launchYear !== null &&
designatorYear !== null &&
designatorYear !== launchYear
)
continue;
const score = scoreSatelliteNameMatch(q, sat.name);
if (score > bestScore) {
bestScore = score;
noradId = id;
}
}
if (bestScore < 12) noradId = null;
}
if (noradId === null) return null;
const sat = layerState._catalog.get(noradId);
return orbitTrackFromRecord(sat.name, sat.satrec);
}
return {
orbitFrameModelMatrix,
parseTLE,
propagatePosition,
orbitalPeriodSeconds,
computeOrbitPath,
getNextIssPass,
scoreSatelliteNameMatch,
internationalDesignatorYear,
lookupTleEntries,
tleLineLaunchYear,
orbitTrackFromRecord,
findSatelliteOrbitTrackInTle,
getSatelliteOrbitTrack,
};
}
+182
View File
@@ -0,0 +1,182 @@
import * as Cesium from 'cesium';
import { satelliteClassColor } from '../../data/satelliteClass.js';
/**
* Satellite Orbits — Real-time positions via CelesTrak TLE + SGP4 propagation.
*
* Loads six CelesTrak groups (~840 sats): stations, visual, GPS, GLONASS,
* Galileo, and the geosynchronous belt. Optional dense mode (setParams
* catalog:'dense') adds the Starlink shell as points-only extras.
* Renders positions via PointPrimitiveCollection, orbital paths as polylines.
* Click any satellite to track it with camera follow + orbital path.
*
* ISS gets special treatment: larger point, persistent host label, path shown by default.
*/
export const ISS_NORAD = 25544;
export const ISS_OVERLAY_SOURCE_ID = 'satellites-iss';
export const ISS_OVERLAY_SOURCE_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 1,
moving: true,
solveIntervalMs: 125,
});
export const ORBIT_PATH_STEPS = 180;
// points per orbital path
export const POSITION_UPDATE_MS = 1000;
// re-propagate every 1s (SGP4 is smooth at this rate)
export const RING_ROTATION_MS = 1000;
// re-align baked orbit rings to current GMST every 1s
/**
* CelesTrak groups loaded as the core catalog, in dedupe-priority order:
* a satellite that appears in multiple groups keeps the FIRST (most specific)
* tag. `path` is the upstream GROUP name forwarded by the /api/celestrak
* proxy; `tag` is the internal group key used for POINT_STYLES lookup.
* Note: CelesTrak's GLONASS group is named 'glo-ops' (not
* 'glonass-operational' — that name 404s upstream).
*/
export const CATALOG_GROUPS = [
{ tag: 'stations', path: 'stations' },
{ tag: 'visual', path: 'visual' },
{ tag: 'gps-ops', path: 'gps-ops' },
{ tag: 'glonass', path: 'glo-ops' },
{ tag: 'galileo', path: 'galileo' },
{ tag: 'geo', path: 'geo' },
];
// Dense-catalog mode (setParams({ catalog: 'dense' })): Starlink shell as
// points-only extras — no labels, no detection-overlay participation, and a
// relaxed propagation budget (round-robin, ~1/5 of core cadence per sat).
export const DENSE_GROUP_PATH = 'starlink';
export const DENSE_REFRESH_FRAMES = 300;
// full dense pass spread over ~300 frames (~5s @ 60fps)
export const DENSE_CREATE_CHUNK = 1500;
// satrec builds per macro-task while loading
/**
* Tracked-entity camera offset, east-north-up meters (Entity.viewFrom).
* Magnitude ≈ 726 km — the user-validated "slightly zoomed out" framing for
* LEO: far enough that per-frame satellite motion doesn't stutter the camera
* or smear the label, with +Z biasing the view down onto the satellite.
* High orbits (MEO nav / GEO belt) scale this up so the ring stays in frame.
*/
export const TRACK_VIEW_FROM_LEO = new Cesium.Cartesian3(
-450000,
-450000,
350000,
);
export const HIGH_ORBIT_ALTITUDE_M = 2000000;
export const TRACK_VIEW_FROM_HIGH_SCALE = 4;
// ≈ 2900 km back for MEO/GEO
/**
* Shared per-group point styling — single source of truth used by BOTH the
* creation site (update) and tracking restore (_clearTracking) so deselecting
* a satellite never loses the original palette (WS-D3).
*
* Colors come from `satelliteClass.js` so the dot, the class label on the
* card, and the legend swatch on the layer row can never disagree. Only
* pixelSize/outline live here — those encode per-group prominence, not class.
* Converted once at module load; per-point color is a plain primitive
* attribute, so classification costs nothing per frame.
*/
export const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3);
export const _classColor = (group) =>
Cesium.Color.fromCssColorString(satelliteClassColor(group));
export const POINT_STYLES = {
// The ISS keeps its own long-standing red hero styling rather than the
// STATION class color: it is the object most users open this layer for, it
// carries a permanent name label, and its size/outline already set it apart.
// Its card still reads "STATION · ISS", so the class stays legible.
iss: {
pixelSize: 12,
color: Cesium.Color.fromCssColorString('#ff4444'),
outlineColor: POINT_OUTLINE,
outlineWidth: 2,
},
stations: {
pixelSize: 8,
color: _classColor('stations'),
outlineColor: POINT_OUTLINE,
outlineWidth: 0,
},
visual: {
pixelSize: 6,
color: _classColor('visual'),
outlineColor: POINT_OUTLINE,
outlineWidth: 0,
},
// Nav constellations (GPS / GLONASS / Galileo) resolve to one shared NAV
// color; 6px so the MEO shells read as clearly as the old visual group did.
'gps-ops': {
pixelSize: 6,
color: _classColor('gps-ops'),
outlineColor: POINT_OUTLINE,
outlineWidth: 0,
},
glonass: {
pixelSize: 6,
color: _classColor('glonass'),
outlineColor: POINT_OUTLINE,
outlineWidth: 0,
},
galileo: {
pixelSize: 6,
color: _classColor('galileo'),
outlineColor: POINT_OUTLINE,
outlineWidth: 0,
},
geo: {
pixelSize: 5,
color: _classColor('geo'),
outlineColor: POINT_OUTLINE,
outlineWidth: 0,
},
// Dense-mode extras (Starlink): dim, small, points-only.
dense: {
pixelSize: 3,
color: _classColor('dense').withAlpha(0.9),
outlineColor: POINT_OUTLINE,
outlineWidth: 0,
},
};
/**
* Physically DOCKED vehicles are separate real tracks sharing one position: the
* station and everything berthed to it sit within metres of each other. Their
* ambient labels therefore stack underneath the tracked card, which is what the
* owner saw. This radius is deliberately tight — it must catch a docked stack
* and nothing else, so an unrelated satellite in a similar orbit is never
* suppressed. Formation-flying pairs are km apart; a docked stack is ~100 m.
*/
export const DOCKED_COMPANION_RADIUS_M = 2000;
/** The scan is O(points); the tracked label is rebuilt every frame, so throttle. */
export const DOCKED_SCAN_INTERVAL_MS = 1000;
/**
* Shared-context refresh interval. The tracked satellite is re-propagated
* every rendered frame; the voice context only needs to be current to about
* the propagation cadence, so this refreshes on the same 1 s beat instead of
* allocating a record 60 times a second.
*/
export const CONTEXT_REFRESH_INTERVAL_MS = 1000;
+320
View File
@@ -0,0 +1,320 @@
import * as Cesium from 'cesium';
import { gstime } from 'satellite.js';
import { ISS_NORAD, POSITION_UPDATE_MS, RING_ROTATION_MS } from './policy.js';
export function createRendering({
state: layerState,
services,
parts,
source,
}) {
const {
focusNowMs,
getFocusTarget,
focusPassIsNeeded,
nearFarScalarValueAtDistance,
advanceSpriteFocus,
focusAlphaNeedsWrite,
} = services.focus;
/**
* Show orbital path for a satellite.
*
* RING FLICKER FIX: this is a one-instance Cesium.Primitive built
* synchronously ONCE (asynchronous: false), then re-aligned to current GMST
* each tick via its modelMatrix. The previous Entity-polyline approach
* rebuilt geometry ASYNCHRONOUSLY on every positions assignment, which made
* both the selected ring and the ISS ring blink once per second while the
* rebuild was in flight. A rigid Z-rotation needs no rebuild at all.
*
* Why not a CallbackProperty entity (dynamic mode)? Verified in Cesium
* 1.138 source: the dynamic polyline updater renders through a shared
* PolylineCollection and applies only the fill material —
* `depthFailMaterial` is silently dropped, losing the dimmed behind-Earth
* segment. The Primitive keeps it via depthFailAppearance + the per-instance
* depthFailColor attribute (same mechanism the entity STATIC batch uses):
* bright where above the horizon, dimmed where behind the globe.
*/
function _showOrbitPath(noradId, color) {
if (layerState._orbitPaths.has(noradId)) return; // already showing
const sat = layerState._catalog.get(noradId);
if (!sat || !layerState._viewer) return;
const bakeDate = new Date();
const basePositions = parts.orbits.computeOrbitPath(sat.satrec, bakeDate);
if (basePositions.length < 2) return;
const pathColor = color || Cesium.Color.CYAN;
const primitive = new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
geometry: new Cesium.PolylineGeometry({
positions: basePositions,
width: noradId === ISS_NORAD ? 2.5 : 2.0,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
pathColor.withAlpha(0.6),
),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
pathColor.withAlpha(0.35),
),
},
}),
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
depthFailAppearance: new Cesium.PolylineColorAppearance({
translucent: true,
}),
asynchronous: false, // build this frame — no async-rebuild blink window
allowPicking: false, // ring clicks fall through to satellites/deselect
});
layerState._viewer.scene.primitives.add(primitive);
layerState._orbitPaths.set(noradId, {
primitive,
// 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.
gmstAtBake: gstime(bakeDate),
});
}
/**
* Rotate every baked orbit ring from its bake-time ECEF snapshot to current
* GMST (WS-D1). The baked ring is an inertial-frame snapshot frozen at
* gmstAtBake; the live dot lives in true rotating-frame ECEF, so without this
* the dot slides west off the ring at Earth-rotation rate (~0.25°/min).
*
* Sign derivation: a point fixed in inertial space keeps its right ascension
* α, so its ECEF longitude λ = α − gmst DECREASES by ΔGMST as time advances
* (it drifts WEST). Cesium.Matrix3.fromRotationZ(θ) rotates +X toward +Y,
* i.e. INCREASES longitude by θ — so we rotate the baked points by −ΔGMST.
* Mental check: ISS baked over Austin at t0; 10 min later Austin has rotated
* east under the (inertial) ring, so the ring must sit further WEST in ECEF.
*
* Wraparound: gstime returns radians in [0, 2π), so the raw difference can be
* off from the continuous ΔGMST — but only by exact multiples of 2π, which a
* rotation cannot distinguish. Tiny negative deltas right after bake are
* likewise harmless. No unwrapping needed.
*
* No SGP4 here — the rotation is applied as the ring primitive's modelMatrix
* (exact compensation; WGS84 is rotationally symmetric about Z, so rotating
* the baked curve rigidly equals rebaking from rotated points). modelMatrix
* updates are synchronous uniforms — no geometry rebuild, no flicker. Post-
* creation modelMatrix changes are supported for one-instance primitives in
* 3D mode, which these rings are. In-place mutation is safe: Primitive.update
* diffs modelMatrix against an internal clone.
* @param {Date} nowDate Epoch used for current GMST.
*/
function _updateOrbitPathRotations(nowDate) {
if (layerState._orbitPaths.size === 0) return;
for (const path of layerState._orbitPaths.values()) {
if (!path.primitive) continue;
parts.orbits.orbitFrameModelMatrix(
path.gmstAtBake,
nowDate,
path.primitive.modelMatrix,
);
}
}
/**
* Remove orbital path for a satellite.
*/
function _hideOrbitPath(noradId) {
const path = layerState._orbitPaths.get(noradId);
if (path) {
if (layerState._viewer)
layerState._viewer.scene.primitives.remove(path.primitive); // remove() destroys
layerState._orbitPaths.delete(noradId);
}
}
/**
* Propagate all CORE satellite positions and update point primitives.
* (~840 sats ≈ 1.6 ms/pass — fine at the 1s/200ms cadence.) Dense extras are
* excluded: they refresh on the round-robin budget in _propagateDenseChunk.
*/
function _propagateAll() {
const now = new Date();
let updated = 0;
for (const [noradId, sat] of layerState._catalog) {
if (sat.group === 'dense') continue;
const pos = parts.orbits.propagatePosition(sat.satrec, now);
if (!pos) continue;
const cartesian = Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
);
const point = layerState._points.get(noradId);
if (point) {
point.position = cartesian;
updated++;
}
}
return updated;
}
/**
* Shared scene.preRender tick (single definition for init + enable):
* - core fleet propagation at 200ms-tracked / 1s-idle cadence,
* - dense extras on a per-frame round-robin budget,
* - tracked satellite's point primitive per frame (WS-D2),
* - orbit ring GMST re-alignment every ~1s (WS-D1).
*/
function _preRenderTick() {
if (!layerState._enabled) return;
const now = focusNowMs(Date.now());
const interval = layerState._trackedNorad ? 200 : POSITION_UPDATE_MS;
// Space Missions keeps this layer enabled for TLE lookup while deliberately
// hiding its standalone fleet. Do not rebuild hidden point buffers on the
// one-second propagation cadence: that GPU upload presented as a periodic
// whole-globe pulse even though the camera remained stationary.
if (
layerState._params.showPoints &&
now - layerState._lastPropagation >= interval
) {
_propagateAll();
layerState._lastPropagation = now;
}
if (layerState._params.showPoints) parts.catalog._propagateDenseChunk();
// Keep the tracked dot on the per-frame epoch shared with label + camera —
// runs after _propagateAll so the per-frame sample wins over the 200ms one.
if (layerState._trackedNorad !== null) {
const pos = parts.tracking._getTrackedFramePosition();
const point = layerState._points.get(layerState._trackedNorad);
if (pos && point) {
point.position = layerState._trackedFrameCartesian; // primitive setter clones
}
}
_updatePointFocus(now);
// Hidden standalone orbit primitives do not need GMST matrix writes while
// Space Missions draws the selected mission orbit itself.
if (
layerState._params.showOrbits &&
now - layerState._lastRingRotation >= RING_ROTATION_MS
) {
_updateOrbitPathRotations(new Date(now));
layerState._lastRingRotation = now;
}
}
/** Focus alpha for satellite points, inside the existing shared preRender tick. */
function _updatePointFocus(nowMs) {
const target = getFocusTarget();
if (
!layerState._params.showPoints ||
!focusPassIsNeeded(target, layerState._activeFocusCount)
)
return;
if (nowMs - layerState._lastFocusUpdate < 80) return;
layerState._lastFocusUpdate = nowMs;
const scene = layerState._viewer.scene;
const camera = layerState._viewer.camera;
const result = applySatellitePointFocusDeemphasis({
points: layerState._points,
trackedId: layerState._trackedNorad,
target,
previousActiveCount: layerState._activeFocusCount,
nowMs,
screenPositionFor: (position) =>
Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
position,
layerState._scratchFocusScreen,
),
cameraDistanceFor: (position) =>
Cesium.Cartesian3.distance(camera.positionWC, position),
baseColorFor: (noradId) =>
parts.controls._pointStyleFor(
noradId,
layerState._catalog.get(noradId)?.group,
).color,
});
layerState._activeFocusCount = result.activeCount;
}
/**
* Apply the gated satellite-point focus pass through the production color path.
* @param {object} input
* @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}}
*/
function applySatellitePointFocusDeemphasis({
points,
trackedId,
target,
previousActiveCount = 0,
nowMs,
screenPositionFor,
cameraDistanceFor,
baseColorFor,
params,
}) {
if (!focusPassIsNeeded(target, previousActiveCount)) {
return { writes: 0, transitioning: false, activeCount: 0, ran: false };
}
let writes = 0;
let transitioning = false;
let activeCount = 0;
for (const [noradId, point] of points || []) {
if (noradId === trackedId || !point?.position) continue;
const cameraDistance = cameraDistanceFor(point.position);
const distanceScale = nearFarScalarValueAtDistance(
point.scaleByDistance,
cameraDistance,
);
const halfExtentPx = (point.pixelSize || 5) * distanceScale * 0.5;
const focus = advanceSpriteFocus(point, {
// Hidden points still release toward identity, preventing stale dim
// alpha if a catalog/presentation toggle later makes them visible.
screenPosition:
point.show === false ? null : screenPositionFor(point.position),
cameraDistance,
nowMs,
target,
params,
spriteHalfWidthPx: halfExtentPx,
spriteHalfHeightPx: halfExtentPx,
});
transitioning ||= focus.transitioning;
if (focus.active) activeCount += 1;
const base = baseColorFor(noradId);
const alpha = base.alpha * focus.factor;
if (focusAlphaNeedsWrite(point.color?.alpha, alpha, params)) {
// Point stays continuously present at the non-zero emphasis floor; its
// own alpha yields around the tracked target, independent of draw order.
point.color = base.withAlpha(alpha);
writes += 1;
}
}
return { writes, transitioning, activeCount, ran: true };
}
return {
_showOrbitPath,
_updateOrbitPathRotations,
_hideOrbitPath,
_propagateAll,
_preRenderTick,
_updatePointFocus,
applySatellitePointFocusDeemphasis,
};
}
+25
View File
@@ -0,0 +1,25 @@
const GROUPS = new Set([
'stations',
'visual',
'gps-ops',
'glo-ops',
'galileo',
'geo',
'starlink',
]);
/** Read catalog text from the existing group endpoint using a supplied transport. */
export function createSatelliteSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async readGroup(group, { signal } = {}) {
if (!GROUPS.has(group)) throw new TypeError('Unknown satellite group');
signal?.throwIfAborted();
const response = await fetchImpl(`/api/celestrak/${group}`, { signal });
const text = response.ok ? await response.text() : '';
signal?.throwIfAborted();
return { ok: response.ok, status: response.status, text };
},
};
}
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSatelliteSource } from './source.js';
import { createSatellitesLayer } from './index.js';
test('satellite sources confine catalog groups and reject a cancelled body', async () => {
const controller = new AbortController();
let requests = 0;
const source = createSatelliteSource({
fetchImpl: async (url) => {
requests++;
assert.equal(url, '/api/celestrak/stations');
return {
ok: true,
status: 200,
text: async () => {
controller.abort();
return 'late catalog';
},
};
},
});
await assert.rejects(
source.readGroup('../active'),
/Unknown satellite group/,
);
assert.equal(requests, 0);
await assert.rejects(
source.readGroup('stations', { signal: controller.signal }),
{ name: 'AbortError' },
);
});
test('satellite factories keep control state separate and construct without requests', () => {
const source = {
readGroup() {
assert.fail('construction fetched a catalog');
},
};
const services = Object.fromEntries(
[
'picking',
'focus',
'readout',
'overlays',
'context',
'render',
'layerState',
].map((key) => [key, {}]),
);
services.layerState.isExplicitLayerStateOrigin = () => false;
const first = createSatellitesLayer({ source, services });
const second = createSatellitesLayer({ source, services });
first.setParams({ showPoints: false });
assert.equal(first.getParams().showPoints, false);
assert.equal(second.getParams().showPoints, true);
assert.notEqual(first.getStats(), second.getStats());
});
+165
View File
@@ -0,0 +1,165 @@
import * as Cesium from 'cesium';
export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
services.overlays;
const state = {};
state.DEFAULT_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
state._overlayHost = state.DEFAULT_OVERLAY_HOST;
// Satellite catalog: { noradId → { name, satrec, group } }
state._catalog = new Map();
state._pointCollection = null;
state._points = new Map();
// noradId → point primitive
/** Stable lightweight records reused by the detection overlay between updates. */
state._detectionObjects = new Map();
// noradId → { primitive, gmstAtBake }
// primitive is a one-instance Cesium.Primitive holding the ring baked in ECEF
// at gmstAtBake; the preRender tick re-aligns it to current GMST by updating
// its modelMatrix (rigid Z-rotation — no geometry rebuild, WS-D1).
state._orbitPaths = new Map();
state._count = 0;
state._lastUpdate = null;
/** @type {string|null} Surfaced feed error (e.g. CelesTrak outage) for the layer chip. */
state._lastError = null;
state._activeUpdateControllers = new Set();
state._denseLoadController = null;
state._viewer = null;
state._preRenderListener = null;
state._lastPropagation = 0;
state._lastRingRotation = 0;
state._lastFocusUpdate = 0;
/** Points whose animated emphasis remains outside the 1.0 deadband. */
state._activeFocusCount = 0;
state._scratchFocusScreen = new Cesium.Cartesian2();
state._enabled = false;
// Click-to-track state
state._trackedNorad = null;
state._pendingTrackingRestore = null;
state._trackingIntentGeneration = 0;
state._trackingRefreshEpoch = 0;
state._lastTrackingRefreshOutcome = {
epoch: 0,
status: 'unavailable',
failedGroups: [],
};
state._trackedEntity = null;
state._clickHandler = null;
/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */
state._trackedEntityChangedRemove = null;
// Runtime params (DataLayerManager.setLayerParams path)
state._params = { catalog: 'core', showPoints: true, showOrbits: true };
// 'core' | 'dense'
state._denseIds = [];
// norad ids of dense extras, round-robin order
state._denseCursor = 0;
// next dense id to re-propagate
state._denseLoadToken = 0;
// invalidates in-flight dense loads on mode flip/reload
state._denseLoadPromise = null;
/**
* Dense-load lifecycle: 'idle' → 'loading' → 'ready' | 'failed'.
* The catalog param flips synchronously but the Starlink shell arrives over
* seconds and can fail outright (CelesTrak 502s this feed regularly), so the
* row chip reports THIS, not the param. An active chip must mean dense points
* are actually on screen.
*/
state._denseStatus = 'idle';
/** @type {string|null} Why the last dense load failed, for the chip tooltip. */
state._denseError = null;
/** Bumped on every bulk catalog mutation; keys the row-legend tally cache. */
state._catalogRevision = 0;
state._classTallyCache = { revision: -1, counts: null };
/** @type {(() => void)|null} Manager callback: "this layer's row controls changed". */
state._rowControlsListener = null;
/** @type {Set<number>} NORAD ids co-located with the tracked satellite. */
state._dockedCompanions = new Set();
state._lastDockedScanMs = Number.NEGATIVE_INFINITY;
// Per-frame cache for the tracked satellite (WS-D2): dot, host readout, camera, and
// getTrackedInfo all read one SGP4 sample per rendered frame, keyed on
// scene.frameState.frameNumber, so they never diverge in epoch.
state._trackedFrameNumber = -1;
state._trackedFrameGeo = null;
// { longitude, latitude, altitude } or null
state._trackedFrameCartesian = new Cesium.Cartesian3();
/** Optional deterministic clock used only by the production-frame test seam. */
state._trackedFrameNowForTest = null;
// Scratch variables
state._scratchCartesian = new Cesium.Cartesian3();
state._scratchRingRotation = new Cesium.Matrix3();
/** Update the explicit model only when the rounded altitude line changes. */
/** Epoch of the last shared-context refresh for the tracked satellite. */
state._contextRefreshedAtMs = 0;
state._lookupTleText = null;
state._lookupTleEntries = [];
return state;
}
+251
View File
@@ -0,0 +1,251 @@
import * as Cesium from 'cesium';
import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js';
export function createTesting({ state: layerState, services, parts, source }) {
/**
* Seed a single tracked satellite so tests can invoke the exact production
* pre-render callback without constructing a WebGL viewer.
* @param {object} state
* @param {number} state.noradId
* @param {string} state.name
* @param {object} state.satrec
* @param {object} state.entity
* @param {object} state.point
* @param {object} state.viewer
* @param {() => (number|Date)} state.now
*/
function _setTrackedSatelliteRefreshStateForTest({
noradId,
name,
satrec,
entity,
point,
viewer,
now,
// Extra catalog/point rows so a test can exercise a docked cluster (and a
// control satellite that must NOT be treated as part of it).
neighbours = [],
}) {
layerState._viewer = viewer;
layerState._catalog = new Map([
[noradId, { name, satrec, group: 'stations' }],
]);
layerState._points = new Map([[noradId, point]]);
for (const neighbour of neighbours) {
layerState._catalog.set(neighbour.noradId, {
name: neighbour.name,
satrec: neighbour.satrec || satrec,
group: neighbour.group || 'stations',
});
layerState._points.set(neighbour.noradId, neighbour.point);
}
layerState._dockedCompanions = new Set();
layerState._lastDockedScanMs = Number.NEGATIVE_INFINITY;
layerState._trackedNorad = noradId;
layerState._trackedEntity = entity;
layerState._trackedFrameNumber = -1;
layerState._trackedFrameGeo = null;
layerState._trackedFrameNowForTest = now;
layerState._params = {
catalog: 'core',
showPoints: false,
showOrbits: false,
};
layerState._enabled = true;
}
/** Seed catalog authority and optional dense settlement for share-Follow tests. */
function _setSatelliteTrackingRefreshOutcomeForTest({
status = 'accepted',
failedGroups = [],
catalog = 'core',
densePromise = null,
} = {}) {
const epoch = ++layerState._trackingRefreshEpoch;
layerState._lastTrackingRefreshOutcome = {
epoch,
status,
failedGroups: [...failedGroups],
};
layerState._params.catalog = catalog;
layerState._denseLoadPromise =
densePromise || Promise.resolve({ status: 'not-requested' });
}
/** The tracked satellite's current ECEF sample — the value the docked-cluster
* scan measures against. Exposed so a test can place neighbours around it. */
function _trackedFrameCartesianForTest() {
return layerState._trackedFrameCartesian;
}
/** Invoke the same callback registered on `scene.preRender` in production. */
function _runSatellitePreRenderForTest() {
parts.rendering._preRenderTick();
}
/**
* Seed the minimum render state a dense-catalog load needs, so a test can
* exercise the real async load/settle/fail path (and the row-control states it
* drives) without constructing a WebGL viewer.
* @param {{ catalog?: 'core'|'dense', showPoints?: boolean }} [options]
*/
function _setDenseCatalogStateForTest({
catalog = 'core',
showPoints = true,
} = {}) {
layerState._viewer = {
scene: { primitives: { add: (p) => p, remove() {} } },
};
// Neutralize the shared world-overlay host: these tests exercise catalog and
// row-control logic, not the ISS callout.
layerState._overlayHost = {
setEntries() {},
setVisible() {},
clearSource() {},
};
layerState._pointCollection = {
show: true,
add: (opts) => ({ ...opts }),
remove() {},
removeAll() {},
};
layerState._catalog = new Map();
layerState._points = new Map();
layerState._detectionObjects = new Map();
layerState._orbitPaths = new Map();
layerState._denseIds = [];
layerState._denseCursor = 0;
layerState._denseLoadToken++;
layerState._denseStatus = 'idle';
layerState._denseError = null;
layerState._catalogRevision++;
layerState._trackedNorad = null;
parts.tracking._cancelPendingTrackingRestore();
layerState._params = { catalog, showPoints, showOrbits: false };
layerState._enabled = true;
}
/** Tear the dense seam back down so ordering cannot leak into other tests. */
function _clearDenseCatalogStateForTest() {
layerState._rowControlsListener = null;
layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST;
layerState._orbitPaths = new Map();
layerState._viewer = null;
layerState._pointCollection = null;
layerState._catalog = new Map();
layerState._points = new Map();
layerState._denseIds = [];
layerState._denseLoadToken++;
layerState._denseStatus = 'idle';
layerState._denseError = null;
layerState._params = {
catalog: 'core',
showPoints: true,
showOrbits: true,
};
parts.tracking._cancelPendingTrackingRestore();
layerState._enabled = false;
}
/** Catalog group tag recorded for a satellite, for ingestion-path assertions. */
function _catalogGroupForTest(noradId) {
return layerState._catalog.get(Number(noradId))?.group;
}
/** Seed ISS/tracking state while retaining the production track and host paths. */
function _setSatelliteLabelLifecycleStateForTest({
viewer,
satrec,
point,
overlayHost,
preservePending = false,
}) {
layerState._viewer = viewer;
layerState._catalog = new Map([
[ISS_NORAD, { name: 'ISS (ZARYA)', satrec, group: 'stations' }],
]);
layerState._points = new Map([[ISS_NORAD, point]]);
layerState._orbitPaths = new Map([
[
ISS_NORAD,
{
primitive: { show: true, modelMatrix: new Cesium.Matrix4() },
gmstAtBake: 0,
},
],
]);
layerState._trackedNorad = null;
layerState._trackedEntity = null;
if (!preservePending) parts.tracking._cancelPendingTrackingRestore();
layerState._trackedFrameNumber = -1;
layerState._trackedFrameGeo = null;
layerState._enabled = true;
layerState._params = {
catalog: 'core',
showPoints: true,
showOrbits: true,
};
layerState._overlayHost = overlayHost || layerState.DEFAULT_OVERLAY_HOST;
parts.labels._syncIssOverlay();
}
/** Exercise production ISS tracking from the cached catalog and point. */
function _trackIssForTest() {
parts.tracking._trackSatellite(ISS_NORAD);
return layerState._trackedEntity;
}
/** Return the deferred restore target held by the production tracker. */
function _pendingSatelliteTrackingRestoreForTest() {
return layerState._pendingTrackingRestore?.id ?? null;
}
/** Exercise the production deferred-restore retry after a simulated catalog refresh. */
function _applyPendingSatelliteTrackingRestoreForTest() {
return parts.tracking._applyPendingTrackingRestore();
}
/** Remove a cached catalog row so tests can model a target arriving later. */
function _removeSatelliteTrackingCandidateForTest(noradId) {
const id = Number(noradId);
layerState._catalog.delete(id);
layerState._points.delete(id);
}
/** Exercise production untrack and restore the default host seam. */
function _clearSatelliteLabelLifecycleForTest() {
parts.tracking._clearTracking();
layerState._enabled = false;
layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID);
layerState._overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false);
layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST;
}
return {
_setTrackedSatelliteRefreshStateForTest,
_setSatelliteTrackingRefreshOutcomeForTest,
_trackedFrameCartesianForTest,
_runSatellitePreRenderForTest,
_setDenseCatalogStateForTest,
_clearDenseCatalogStateForTest,
_catalogGroupForTest,
_setSatelliteLabelLifecycleStateForTest,
_trackIssForTest,
_pendingSatelliteTrackingRestoreForTest,
_applyPendingSatelliteTrackingRestoreForTest,
_removeSatelliteTrackingCandidateForTest,
_clearSatelliteLabelLifecycleForTest,
};
}
+479
View File
@@ -0,0 +1,479 @@
import * as Cesium from 'cesium';
import { satelliteClassLabel } from '../../data/satelliteClass.js';
import {
ISS_NORAD,
CONTEXT_REFRESH_INTERVAL_MS,
HIGH_ORBIT_ALTITUDE_M,
TRACK_VIEW_FROM_HIGH_SCALE,
TRACK_VIEW_FROM_LEO,
} from './policy.js';
export function createTracking({ state: layerState, services, parts, source }) {
const { clearFocusTarget, publishFocusTargetFromCachedPosition } =
services.focus;
const {
clearTrackedSubjectContext,
getContextStore,
refreshTrackedSubjectContext,
selectTrackedSubjectContext,
} = services.context;
const { refreshTrackedReadout } = services.readout;
function _normalizeTrackedNorad(candidate) {
const numeric = Number(candidate);
if (!Number.isFinite(numeric)) return null;
const rounded = Math.trunc(numeric);
return rounded > 0 ? rounded : null;
}
function _emitAwarenessEvent(type, detail) {
if (
typeof window === 'undefined' ||
!window.dispatchEvent ||
typeof CustomEvent === 'undefined'
)
return;
window.dispatchEvent(new CustomEvent(type, { detail }));
}
function _applyPendingTrackingRestore() {
const pending = layerState._pendingTrackingRestore;
if (
!pending ||
pending.generation !== layerState._trackingIntentGeneration ||
!layerState._enabled
)
return false;
if (
!layerState._viewer ||
!layerState._catalog.has(pending.id) ||
!layerState._points.has(pending.id)
)
return false;
layerState._pendingTrackingRestore = null;
_trackSatellite(pending.id, { origin: pending.origin });
return layerState._trackedNorad === pending.id;
}
function _cancelPendingTrackingRestore() {
layerState._trackingIntentGeneration += 1;
layerState._pendingTrackingRestore = null;
}
/**
* Stop tracking the currently followed satellite.
* @param {boolean} [skipViewerUntrack=false] - When ANOTHER layer just grabbed
* the follow-camera (viewer.trackedEntityChanged), tear down our own state
* but do NOT clear viewer.trackedEntity — the new owner controls it now,
* and clearing it would yank the camera off their target (mirror of flights).
*/
function _clearTracking(
skipViewerUntrack = false,
{ origin = 'programmatic' } = {},
) {
// Untracking dissolves the cluster: every companion returns to its own
// ambient label on the next collection.
layerState._dockedCompanions = new Set();
layerState._lastDockedScanMs = Number.NEGATIVE_INFINITY;
if (!layerState._trackedNorad) {
clearFocusTarget('satellites');
parts.labels._syncIssOverlay();
return;
}
const clearedNorad = layerState._trackedNorad;
clearFocusTarget('satellites', layerState._trackedNorad);
const lastPos = layerState._points.get(layerState._trackedNorad);
// Re-show the primitive (hidden while the tracked entity rendered the dot)
// and restore the original group palette from the shared style table (WS-D3)
if (lastPos) {
const style = parts.controls._pointStyleFor(
layerState._trackedNorad,
layerState._catalog.get(layerState._trackedNorad)?.group,
);
lastPos.show = true;
lastPos.pixelSize = style.pixelSize;
lastPos.color = style.color;
lastPos.outlineColor = style.outlineColor;
lastPos.outlineWidth = style.outlineWidth;
lastPos.disableDepthTestDistance = 0;
}
// Invalidate the per-frame tracked-position cache (WS-D2)
layerState._trackedFrameNumber = -1;
layerState._trackedFrameGeo = null;
// Remove tracked entity and orbit path (unless ISS — keep its path)
if (layerState._trackedNorad !== ISS_NORAD) {
parts.rendering._hideOrbitPath(layerState._trackedNorad);
}
if (layerState._viewer && !skipViewerUntrack)
layerState._viewer.trackedEntity = undefined;
if (layerState._trackedEntity) {
layerState._viewer.entities.remove(layerState._trackedEntity);
layerState._trackedEntity = null;
}
layerState._trackedNorad = null;
parts.labels._syncIssOverlay();
clearTrackedSubjectContext('satellites');
layerState._contextRefreshedAtMs = 0;
_emitAwarenessEvent('gev:awareness-subject-cleared', {
layerId: 'satellites',
id: clearedNorad,
origin,
});
}
/**
* Get the tracked satellite's geodetic position, propagated at most once per
* rendered frame (WS-D2). All tracked-satellite consumers (entity position
* callback → camera, host model, point primitive, getTrackedInfo) share this
* single `new Date()` epoch per frame, so they can never diverge by the old
* 200ms throttle. The matching ECEF position is left in
* `_trackedFrameCartesian`. SGP4 for one satellite per frame is cheap.
* @returns {{ longitude: number, latitude: number, altitude: number }|null}
*/
function _getTrackedFramePosition() {
if (layerState._trackedNorad === null) return null;
const sat = layerState._catalog.get(layerState._trackedNorad);
if (!sat) return null;
const frameNumber =
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
if (
frameNumber === -1 ||
frameNumber !== layerState._trackedFrameNumber ||
layerState._trackedFrameGeo === null
) {
const pos = parts.orbits.propagatePosition(
sat.satrec,
layerState._trackedFrameNowForTest
? new Date(layerState._trackedFrameNowForTest())
: new Date(),
);
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
layerState._trackedFrameGeo = pos;
Cesium.Cartesian3.fromDegrees(
pos.longitude,
pos.latitude,
pos.altitude,
undefined,
layerState._trackedFrameCartesian,
);
layerState._trackedFrameNumber = frameNumber;
// Throttled inside; membership changes are rare, so resync the ISS ambient
// gate only when the cluster actually changed.
const clusterChanged = parts.labels._refreshDockedCompanions(
layerState._trackedFrameNowForTest
? layerState._trackedFrameNowForTest()
: Date.now(),
);
_updateTrackedSatelliteLabelModel();
// The card and the context slot describe the same satellite — keep them
// together so voice never narrates a fix the card has already replaced.
_refreshTrackedSubjectContext();
if (clusterChanged) parts.labels._syncIssOverlay();
const name = sat.name?.trim() || `SAT-${layerState._trackedNorad}`;
const altitudeText = `${Math.round(pos.altitude / 1000)} km · NORAD ${layerState._trackedNorad}`;
const labelWidthPx =
Math.max(name.length, altitudeText.length) * 7.8 + 20;
const labelHeightPx = 2 * 13 + 12;
const trackedPointDiameterPx = 14 + 4; // point plus its 2 px outline on both sides
// Exact SGP4 frame cache only: dot, host readout, camera, and focus rectangle all
// share one epoch, avoiding a second propagation phase and its old jitter.
publishFocusTargetFromCachedPosition({
ownerLayer: 'satellites',
id: layerState._trackedNorad,
scene: layerState._viewer?.scene,
camera: layerState._viewer?.camera,
displayPosition: layerState._trackedFrameCartesian,
widthPx: Math.max(trackedPointDiameterPx, Math.min(260, labelWidthPx)),
// Union of the 14 px point and the two-line label shifted 18 px upward.
heightPx: trackedPointDiameterPx + 18 + labelHeightPx,
});
}
return layerState._trackedFrameGeo;
}
/** Cached ECEF display point only; never propagates a fresh SGP4 sample. */
function _trackedDisplayCached() {
return layerState._trackedFrameGeo
? layerState._trackedFrameCartesian
: null;
}
/**
* Describe the tracked satellite for the shared context slot the voice tools
* read. Values come from the live per-frame propagation, not a selection-time
* snapshot, so a long follow never narrates a position the satellite has left.
* @param {number} noradId Catalog identity.
* @param {{latitude: number, longitude: number, altitude: number}|null} [position]
* Explicit position; defaults to the current per-frame propagation.
* @returns {object|null} Context metadata, or null when the satellite is gone.
*/
function _contextSubjectMetadata(noradId, position = null) {
const sat = layerState._catalog.get(noradId);
if (!sat) return null;
const pos = position || _getTrackedFramePosition();
if (!pos) return null;
const name = sat.name?.trim() || `SAT-${noradId}`;
const altitudeKm = Number.isFinite(pos.altitude)
? Math.round(pos.altitude / 1000)
: null;
return {
id: String(noradId),
layerId: 'satellites',
layerName: 'Satellites',
source: 'CelesTrak',
label: name,
latitude: pos.latitude,
longitude: pos.longitude,
// Flat text only: the voice payload compacts properties through a string
// cleaner that drops nested objects.
properties: {
name,
operator: '',
noradId: String(noradId),
class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }),
altitude:
altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`,
},
};
}
/**
* Reconcile the published subject with a freshly rebuilt catalog.
*
* A rebuild (dense↔core toggle, TLE refresh) clears and repopulates the
* catalog, so the tracked satellite's entry is a NEW object with a new satrec.
* A surviving subject is simply re-resolved against it. A subject that is GONE
* must release the slot: the per-frame refresh cannot do this itself, because
* `_getTrackedFramePosition` returns early once the satellite has no catalog
* entry — so without this the record would linger and voice would narrate a
* satellite the catalog no longer carries, frozen at its last position.
*
* Releasing is gated on PROOF. The subject is preserved unless it is absent
* from a catalog that is both complete (`accepted`, no failed CelesTrak group)
* and applicable (dense settled, when dense is the requested catalog). A
* partial refresh, a failed dense load, or an empty catalog is unproven
* absence, and unproven absence is not absence — the same honesty rule the
* tracking-restore path already applies.
* @returns {Promise<void>} Resolves once the applicable catalog has settled.
*/
async function _reconcileTrackedSubjectContext() {
const subjectAtStart = layerState._trackedNorad;
if (subjectAtStart === null) return;
// Dense extras land AFTER the core rebuild resolves. Deciding before they
// settle called a dense subject missing and deleted its record; the record
// then stayed gone, because a refresh can update an existing record but
// cannot recreate one.
const denseSettlement =
layerState._params.catalog === 'dense'
? layerState._denseLoadPromise
: null;
if (denseSettlement) {
try {
await denseSettlement;
} catch {
// A failed dense load proves nothing about the subject; fall through and
// let the outcome check below preserve it.
}
}
// The operator may have moved on while we waited.
if (layerState._trackedNorad !== subjectAtStart) return;
const metadata = _contextSubjectMetadata(subjectAtStart);
if (metadata) {
const store = getContextStore();
const key = String(subjectAtStart);
if (store.entities.has(key)) {
refreshTrackedSubjectContext(metadata);
} else if (!store.selectedEntityId) {
// The record was dropped while the subject was briefly unresolvable.
// Restore it — but only into an EMPTY slot: a satellite reappearing must
// never yank the subject away from something the operator selected since.
selectTrackedSubjectContext(metadata);
}
layerState._contextRefreshedAtMs = Date.now();
return;
}
// Absence only counts when EVERY catalog that could carry the subject
// actually loaded. Unproven absence is not absence — the same honesty rule
// the tracking-restore path applies.
//
// Deliberately NOT scoped to the group the subject was last seen in:
// CelesTrak reclassifies satellites between groups, so a subject missing
// from its old group may simply have moved to one that failed this refresh.
// Believing the old group alone would drop it. Any failed or empty group is
// therefore a reason to wait — and `accepted` already means every group
// returned entries.
if (layerState._catalog.size === 0) return;
if (layerState._lastTrackingRefreshOutcome?.status !== 'accepted') return;
// Dense is a potential carrier too whenever it was REQUESTED — and the
// request is read from `denseSettlement`, captured before the await, not
// from `_params.catalog` now. A failed dense load reverts the mode to 'core'
// as part of settling (an ACTIVE chip over an empty sky would be a lie), so
// by the time we get here the intent that made dense a carrier has been
// erased. Re-reading it would skip this guard on exactly the runs that need
// it and delete a subject the dense catalog might have carried.
if (denseSettlement && layerState._denseStatus !== 'ready') return;
clearTrackedSubjectContext('satellites');
layerState._contextRefreshedAtMs = 0;
}
/**
* Keep the shared context slot current with the tracked satellite, on the
* propagation beat rather than the frame clock.
* @returns {void}
*/
function _refreshTrackedSubjectContext() {
if (layerState._trackedNorad === null) return;
const now = Date.now();
if (now - layerState._contextRefreshedAtMs < CONTEXT_REFRESH_INTERVAL_MS)
return;
layerState._contextRefreshedAtMs = now;
refreshTrackedSubjectContext(
_contextSubjectMetadata(layerState._trackedNorad),
);
}
function _updateTrackedSatelliteLabelModel(fallbackAltitudeM = null) {
if (!layerState._trackedEntity || layerState._trackedNorad === null) return;
const sat = layerState._catalog.get(layerState._trackedNorad);
const title = sat?.name?.trim() || `SAT-${layerState._trackedNorad}`;
const altitudeM =
layerState._trackedFrameGeo?.altitude ?? fallbackAltitudeM;
const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${layerState._trackedNorad}`;
// Class leads the detail block: it is what tells the operator WHAT they are
// looking at, and it stays readable under the IR styles that flatten the
// dot colors to a single channel (the card is painted above post-FX).
const details = [
satelliteClassLabel(sat?.group, {
isIss: layerState._trackedNorad === ISS_NORAD,
}),
detail,
];
// Docked companions are consolidated onto the tracked card as SECONDARY info
// instead of competing with it as separate ambient labels. Identities are
// preserved: the catalog is untouched and every companion returns to its own
// label the moment the cluster is no longer tracked.
const companions = parts.labels._dockedCompanionNames();
if (companions.length > 0) {
const extra = companions.length - 1;
details.push(
`DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`,
);
}
const current = layerState._trackedEntity.gevLabelModel;
// Compare the WHOLE detail array: comparing only `details[0]` swallowed any
// change confined to the companions line, so the card would never republish.
const unchanged =
current?.title === title &&
current?.details?.length === details.length &&
details.every((line, index) => current.details[index] === line);
if (unchanged) return;
layerState._trackedEntity.gevLabelModel = {
title,
details,
accent: '#ffd84d',
};
refreshTrackedReadout(layerState._trackedEntity);
}
function _trackSatellite(noradId, { origin = 'programmatic' } = {}) {
_clearTracking(false, { origin });
const point = layerState._points.get(noradId);
const sat = layerState._catalog.get(noradId);
if (!point || !sat) return;
layerState._trackedNorad = noradId;
layerState._trackedFrameNumber = -1;
layerState._trackedFrameGeo = null;
parts.labels._syncIssOverlay();
// Hide the primitive — the tracked ENTITY renders the dot below. The
// entity must own a point graphic so the Viewer's tracking camera can
// resolve a bounding sphere and engage viewFrom (a label-only entity
// left the camera stranded; this mirrors the proven flights pattern).
point.show = false;
// Show orbital path
parts.rendering._showOrbitPath(noradId, Cesium.Color.YELLOW);
// Tracked entity position propagates per evaluation through the per-frame
// cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame.
// Falls back to the point primitive's 1s-throttled position if SGP4 fails.
const positionProperty = new Cesium.CallbackProperty(() => {
const pos = _getTrackedFramePosition();
return pos ? layerState._trackedFrameCartesian : point.position;
}, false);
const name = sat.name.trim();
// Comfortable tracking landing: ~726 km back for LEO (user-validated
// "slightly zoomed out" framing — no stutter, label reads cleanly), scaled
// up for MEO/GEO so the camera doesn't land on top of a high-orbit dot.
const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date());
const viewScale =
initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M
? TRACK_VIEW_FROM_HIGH_SCALE
: 1;
const viewFrom = Cesium.Cartesian3.multiplyByScalar(
TRACK_VIEW_FROM_LEO,
viewScale,
new Cesium.Cartesian3(),
);
layerState._trackedEntity = layerState._viewer.entities.add({
position: positionProperty,
viewFrom,
point: {
pixelSize: 14,
color: Cesium.Color.YELLOW,
outlineColor: Cesium.Color.WHITE,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
layerState._trackedEntity.gevSelectionOrigin = origin;
layerState._trackedEntity.gevTrackedId = `satellites:${noradId}`;
layerState._trackedEntity.gevDisplayPosition = _trackedDisplayCached;
_updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null);
_emitAwarenessEvent('gev:awareness-subject-selected', {
layerId: 'satellites',
id: noradId,
label: name,
position: Cesium.Cartesian3.clone(point.position),
origin,
});
selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos));
layerState._contextRefreshedAtMs = Date.now();
layerState._viewer.trackedEntity = layerState._trackedEntity;
console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`);
}
return {
_normalizeTrackedNorad,
_emitAwarenessEvent,
_applyPendingTrackingRestore,
_cancelPendingTrackingRestore,
_clearTracking,
_getTrackedFramePosition,
_trackedDisplayCached,
_contextSubjectMetadata,
_reconcileTrackedSubjectContext,
_refreshTrackedSubjectContext,
_updateTrackedSatelliteLabelModel,
_trackSatellite,
};
}