mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
- Satellite class in analyst records and voice payloads stays English
("STATION", "COMMS"), so voice filters on the class work in every
language; cards and the legend still show the translated label.
- Layer source names (OSM routing, ALPR sources) stay English in layer
state; the layer row translates them.
- New translateKnown(): catalog text when there is an entry, the given text
otherwise, with no warning. The layer row uses it for loading labels and
errors, Recent Imagery for its notices.
- Mark the remaining Recent Imagery and wind messages.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
483 lines
19 KiB
JavaScript
483 lines
19 KiB
JavaScript
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,
|
|
localize: false,
|
|
}),
|
|
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,
|
|
};
|
|
}
|