mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
575 lines
20 KiB
JavaScript
575 lines
20 KiB
JavaScript
import {
|
|
tallySatelliteClasses,
|
|
satelliteClassLabel,
|
|
satelliteClassLegend,
|
|
} from '../../data/satelliteClass.js';
|
|
import * as Cesium from 'cesium';
|
|
import { t } from '../../i18n/index.js';
|
|
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 = t(
|
|
'Add the full Starlink broadband shell (thousands of extra points)',
|
|
);
|
|
if (loading) title = t('Loading the Starlink shell…');
|
|
else if (failed)
|
|
title = t('Starlink {detail} — click to retry', {
|
|
detail: layerState._denseError || t('load failed'),
|
|
});
|
|
else if (active)
|
|
title = t(
|
|
'Showing the full Starlink shell — click for the core catalog only',
|
|
);
|
|
return {
|
|
chips: [
|
|
{
|
|
id: 'catalog',
|
|
label: loading
|
|
? t('DENSE ···')
|
|
: failed
|
|
? t('DENSE ✕')
|
|
: t('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() {
|
|
const stale = Boolean(layerState._coreStale || layerState._denseStale);
|
|
return {
|
|
count: layerState._count,
|
|
lastUpdate: layerState._lastUpdate,
|
|
stale,
|
|
status:
|
|
layerState._lastError === 'CelesTrak unreachable'
|
|
? 'unavailable'
|
|
: layerState._lastError
|
|
? 'degraded'
|
|
: stale
|
|
? 'stale'
|
|
: 'nominal',
|
|
error: layerState._lastError,
|
|
};
|
|
},
|
|
};
|
|
|
|
return {
|
|
_pointStyleFor,
|
|
_notifyRowControls,
|
|
_classTally,
|
|
satelliteVisualsVisible,
|
|
satelliteCatalogModeChanged,
|
|
methods,
|
|
};
|
|
}
|