From ce0568983a1177a8b6d95a1fd1dd2407ab7637cf Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 13:12:34 -0700 Subject: [PATCH] refactor(space): separate satellite and mission layer components --- CHANGELOG.md | 8 + docs/CURRENT-STATE.md | 9 + package.json | 4 +- scripts/format-scope.json | 39 +- scripts/package-boundaries.json | 47 + src/data/rocketLaunches.js | 3776 +------------------------ src/data/satellites.js | 2445 +--------------- src/layers/launches/camera.js | 375 +++ src/layers/launches/controls.js | 35 + src/layers/launches/index.js | 118 + src/layers/launches/ingestion.js | 268 ++ src/layers/launches/launchPad.js | 158 ++ src/layers/launches/lifecycle.js | 172 ++ src/layers/launches/model.js | 241 ++ src/layers/launches/orbitRendering.js | 183 ++ src/layers/launches/overlays.js | 616 ++++ src/layers/launches/panel.js | 543 ++++ src/layers/launches/paths.js | 588 ++++ src/layers/launches/policy.js | 102 + src/layers/launches/policyHelpers.js | 256 ++ src/layers/launches/rendering.js | 573 ++++ src/layers/launches/replay.js | 557 ++++ src/layers/launches/selection.js | 239 ++ src/layers/launches/source.js | 25 + src/layers/launches/source.test.mjs | 53 + src/layers/launches/state.js | 138 + src/layers/launches/testing.js | 19 + src/layers/satellites/catalog.js | 239 ++ src/layers/satellites/controls.js | 562 ++++ src/layers/satellites/index.js | 73 + src/layers/satellites/ingestion.js | 193 ++ src/layers/satellites/interaction.js | 86 + src/layers/satellites/labels.js | 125 + src/layers/satellites/lifecycle.js | 179 ++ src/layers/satellites/orbits.js | 327 +++ src/layers/satellites/policy.js | 182 ++ src/layers/satellites/rendering.js | 320 +++ src/layers/satellites/source.js | 25 + src/layers/satellites/source.test.mjs | 58 + src/layers/satellites/state.js | 165 ++ src/layers/satellites/testing.js | 251 ++ src/layers/satellites/tracking.js | 479 ++++ src/radioMarkup.test.mjs | 3 +- src/testSupport/readLayerSource.mjs | 8 +- 44 files changed, 8767 insertions(+), 6095 deletions(-) create mode 100644 src/layers/launches/camera.js create mode 100644 src/layers/launches/controls.js create mode 100644 src/layers/launches/index.js create mode 100644 src/layers/launches/ingestion.js create mode 100644 src/layers/launches/launchPad.js create mode 100644 src/layers/launches/lifecycle.js create mode 100644 src/layers/launches/model.js create mode 100644 src/layers/launches/orbitRendering.js create mode 100644 src/layers/launches/overlays.js create mode 100644 src/layers/launches/panel.js create mode 100644 src/layers/launches/paths.js create mode 100644 src/layers/launches/policy.js create mode 100644 src/layers/launches/policyHelpers.js create mode 100644 src/layers/launches/rendering.js create mode 100644 src/layers/launches/replay.js create mode 100644 src/layers/launches/selection.js create mode 100644 src/layers/launches/source.js create mode 100644 src/layers/launches/source.test.mjs create mode 100644 src/layers/launches/state.js create mode 100644 src/layers/launches/testing.js create mode 100644 src/layers/satellites/catalog.js create mode 100644 src/layers/satellites/controls.js create mode 100644 src/layers/satellites/index.js create mode 100644 src/layers/satellites/ingestion.js create mode 100644 src/layers/satellites/interaction.js create mode 100644 src/layers/satellites/labels.js create mode 100644 src/layers/satellites/lifecycle.js create mode 100644 src/layers/satellites/orbits.js create mode 100644 src/layers/satellites/policy.js create mode 100644 src/layers/satellites/rendering.js create mode 100644 src/layers/satellites/source.js create mode 100644 src/layers/satellites/source.test.mjs create mode 100644 src/layers/satellites/state.js create mode 100644 src/layers/satellites/testing.js create mode 100644 src/layers/satellites/tracking.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b35b8f7..c42fe59f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Satellite and mission layer components + +- Separate catalog loading, orbit calculations, display, tracking and interaction + into instance-owned satellite components. +- Separate mission ingestion, paths, placement, cards, roster, replay and camera + operations, retaining existing layer controls and satellite coordination. +- Cancel late mission source work and reject malformed launch snapshots. + ## Fire layer components - Split fire source loading, state, rendering, cards, selection and viewport work diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 368461efd..34cb5b8e3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,14 @@ # God's Eye View Current State +Satellite and mission layers expose separate factories through `./layers/satellites` +and `./layers/launches`. Each owns its catalog, render state, tracking, interaction +and teardown. Source adapters retain the existing CelesTrak and Launch Library +endpoints; standalone entries provide scene services and the satellite dependency. +The mission factory separates paths, replay, camera, panel, overlays and placement. +Pending mission requests abort on disable/destruction and malformed launch +snapshots preserve the last accepted display. Catalog groups, propagation cadence, +tracking intent, replay timing, controls and source attribution remain unchanged. + The fire layer now exposes `./layers/firms`: an instance factory with separate snapshot requests, records, rendering, cards, selection, viewport scheduling and terrain-anchor batching. The standalone entry supplies the existing FIRMS source diff --git a/package.json b/package.json index d69f93e4c..fc5b7bdfd 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,8 @@ "./aircraft": "./src/layers/aircraft/index.js", "./layers/vessels": "./src/layers/vessels/index.js", "./layers/earthquakes": "./src/layers/earthquakes/index.js", - "./layers/firms": "./src/layers/firms/index.js" + "./layers/firms": "./src/layers/firms/index.js", + "./layers/satellites": "./src/layers/satellites/index.js", + "./layers/launches": "./src/layers/launches/index.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6c7e3631a..89f291857 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -323,5 +323,42 @@ "src/layers/firms/source.js", "src/layers/firms/state.js", "src/layers/firms/viewport.js", - "src/layers/firms/source.test.mjs" + "src/layers/firms/source.test.mjs", + "src/layers/satellites/lifecycle.js", + "src/layers/satellites/interaction.js", + "src/layers/satellites/controls.js", + "src/layers/satellites/source.js", + "src/layers/satellites/labels.js", + "src/layers/satellites/catalog.js", + "src/layers/satellites/state.js", + "src/layers/satellites/policy.js", + "src/layers/satellites/tracking.js", + "src/layers/satellites/rendering.js", + "src/layers/satellites/testing.js", + "src/layers/satellites/ingestion.js", + "src/layers/satellites/index.js", + "src/layers/satellites/orbits.js", + "src/layers/launches/lifecycle.js", + "src/layers/launches/controls.js", + "src/layers/launches/panel.js", + "src/layers/launches/source.js", + "src/layers/launches/policyHelpers.js", + "src/layers/launches/replay.js", + "src/layers/launches/launchPad.js", + "src/layers/launches/state.js", + "src/layers/launches/policy.js", + "src/layers/launches/rendering.js", + "src/layers/launches/testing.js", + "src/layers/launches/ingestion.js", + "src/layers/launches/overlays.js", + "src/layers/launches/orbitRendering.js", + "src/layers/launches/index.js", + "src/layers/launches/paths.js", + "src/layers/launches/model.js", + "src/layers/launches/selection.js", + "src/layers/launches/camera.js", + "src/data/satellites.js", + "src/data/rocketLaunches.js", + "src/layers/satellites/source.test.mjs", + "src/layers/launches/source.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 84bbb1f5b..74cdfb0d0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -716,5 +716,52 @@ "src/layers/firms/viewport.js" ], "external": ["cesium"] + }, + "satellites-layer": { + "exports": ["./layers/satellites"], + "modules": [ + "src/data/issPass.js", + "src/data/satelliteClass.js", + "src/layers/satellites/catalog.js", + "src/layers/satellites/controls.js", + "src/layers/satellites/index.js", + "src/layers/satellites/ingestion.js", + "src/layers/satellites/interaction.js", + "src/layers/satellites/labels.js", + "src/layers/satellites/lifecycle.js", + "src/layers/satellites/orbits.js", + "src/layers/satellites/policy.js", + "src/layers/satellites/rendering.js", + "src/layers/satellites/source.js", + "src/layers/satellites/state.js", + "src/layers/satellites/testing.js", + "src/layers/satellites/tracking.js" + ], + "external": ["cesium", "satellite.js"] + }, + "launches-layer": { + "exports": ["./layers/launches"], + "modules": [ + "src/layers/launches/camera.js", + "src/layers/launches/controls.js", + "src/layers/launches/index.js", + "src/layers/launches/ingestion.js", + "src/layers/launches/launchPad.js", + "src/layers/launches/lifecycle.js", + "src/layers/launches/model.js", + "src/layers/launches/orbitRendering.js", + "src/layers/launches/overlays.js", + "src/layers/launches/panel.js", + "src/layers/launches/paths.js", + "src/layers/launches/policy.js", + "src/layers/launches/policyHelpers.js", + "src/layers/launches/rendering.js", + "src/layers/launches/replay.js", + "src/layers/launches/selection.js", + "src/layers/launches/source.js", + "src/layers/launches/state.js", + "src/layers/launches/testing.js" + ], + "external": ["cesium"] } } diff --git a/src/data/rocketLaunches.js b/src/data/rocketLaunches.js index c52b9d20d..c63b5e7a9 100644 --- a/src/data/rocketLaunches.js +++ b/src/data/rocketLaunches.js @@ -1,3700 +1,82 @@ -import * as Cesium from 'cesium'; import { - findSatelliteOrbitTrackInTle, - getSatelliteOrbitTrack, - orbitFrameModelMatrix, -} from './satellites.js'; -import { getKeyholeGeometry } from '../celestialRing.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; + createRocketLaunchesLayer, + createLaunchSource, +} from '../layers/launches/index.js'; +import * as satellites from './satellites.js'; +import * as geometry from '../celestialRing.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as render from '../renderGovernor.js'; -const WINDOW_DAYS = 30; -const API_URL = '/api/launches'; - -export const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID = 'rocket-missions'; -export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID = 'rocket-mission-selected'; -export const ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT = 48; -export const ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY = 24; -export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: 12, - collisionCapacity: 0, - moving: true, - solveIntervalMs: 0, +const layer = createRocketLaunchesLayer({ + source: createLaunchSource(), + services: { satellites, geometry, overlays, render }, }); - -const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS = Object.freeze({ - cohortLimit: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, - collisionCapacity: ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, - moving: false, -}); -const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); - -let _dataSource = null; -let _count = 0; -let _lastUpdate = null; -let _lastError = null; -let _orbitMatches = 0; -let _clickHandler = null; -let _moveHandler = null; -let _viewer = null; -let _declutterHandler = null; -let _dataManager = null; -let _retryTimer = null; -let _postTleRetryCount = 0; -let _updatePromise = null; -let _updatePromiseToken = 0; -let _updateDirty = false; -let _lifecycleToken = 0; -let _enabled = false; -let _selectedLaunchId = null; -let _explicitSelection = false; -let _launches = []; -let _missionPanel = null; -let _missionRoster = null; -let _missionRosterHoverTimer = null; -let _hoveredRosterLaunchId = null; -let _missionRosterPreviewOwnership = null; -let _missionHoverReticleImage = null; -let _replayVehicleOverlay = null; -let _replayVehicleOverlayText = ''; -const _animationStarts = new Map(); -const _satelliteTelemetry = new Map(); -let _lastPanelTelemetryMs = 0; -let _lastMissionRingRotationMs = 0; -let _activeTleText = null; -let _activeTlePromise = null; -let _activeTlePromiseToken = 0; -let _renderedTleText = null; -let _focusAfterActiveLookup = false; -let _satelliteStateBeforeMission = null; -let _satelliteActivationPromise = null; -let _replayCameraRemover = null; -let _replayCameraLaunchId = null; -let _replayCameraToken = 0; -let _replayPaused = false; -let _replayPausedAtMs = null; -let _replaySpeed = 1; -const _replayTracks = new Map(); -const _missionOverlayRecords = new Map(); -let _missionOverlayHost = DEFAULT_OVERLAY_HOST; -let _selectedMissionOverlayTimeText = null; -let _missionZoomAnchorRemover = null; -let _missionZoomAnchorId = null; -let _launchPadZonePrimitive = null; -let _launchPadZoneRemover = null; -let _launchPadZoneLaunchId = null; -const _missionOrbitPrimitives = new Map(); - -const REPLAY_ASCENT_FALLBACK_SEC = 12; -const REPLAY_ASCENT_MIN_SEC = 8; -const REPLAY_ASCENT_MAX_SEC = 36; -const REPLAY_ORBIT_DURATION_SEC = 28; -const REPLAY_COUNTDOWN_DURATION_SEC = 10; -const REPLAY_TILE_SETTLE_DELAY_SEC = 5; -const REPLAY_INITIAL_RANGE_M = 3500; -const REPLAY_LOCAL_MAX_RANGE_M = 900000; -const REPLAY_CONTEXT_MAX_RANGE_M = 2400000; -const REPLAY_CONTEXT_ALTITUDE_END_M = 420000; -const REPLAY_ORBIT_GLOBE_RANGE_M = 18000000; -const REPLAY_ORBIT_PULLBACK_FRACTION = 0.2; -const REPLAY_ASCENT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(30); -const REPLAY_ORBIT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(45); -const REPLAY_ORBIT_FRAME_CENTER_BLEND = 0.45; -const REPLAY_SPEED_MIN = 0.25; -const REPLAY_SPEED_MAX = 4; -const REPLAY_SPEED_STEP = 0.25; -const MAX_POST_TLE_RETRIES = 1; -const POST_TLE_RETRY_DELAY_MS = 1500; -const _declutterTime = new Cesium.JulianDate(); -const _declutterOccluder = new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - new Cesium.Cartesian3(), -); -const _missionRingDate = new Date(0); -const EARTH_ROTATION_RAD_PER_SEC = Cesium.Math.TWO_PI / 86164.0905; -const PROJECTED_ASCENT_ROTATION_SEC = 600; -const STAGE_REENTRY_ALTITUDE_M = 100000; -const MISSION_CLOSE_VIEW_RANGE_M = 180000; -const MISSION_GLOBE_VIEW_RANGE_M = 5000000; -const MISSION_FOCUS_RANGE_M = 12000; -const SATELLITE_STANDALONE_DEFAULTS = { - catalog: 'core', - showPoints: true, - showOrbits: true, -}; - -/** - * Derive the temporary Satellite display mode required by Space Missions. - * @param {object|null} currentParams Complete pre-mission Satellite parameters. - * @returns {object} Temporary mission-specific Satellite parameters. - */ -export function satelliteParamsForSpaceMissions(currentParams) { - return { - ...SATELLITE_STANDALONE_DEFAULTS, - ...(currentParams || {}), - catalog: 'dense', - showPoints: false, - showOrbits: false, - }; -} - -/** - * Resolve the complete Satellite parameter set restored after mission mode. - * @param {object|null} snapshot Complete pre-mission Satellite parameters. - * @returns {object} Standalone Satellite parameters. - */ -export function satelliteParamsAfterSpaceMissions(snapshot) { - return { - ...SATELLITE_STANDALONE_DEFAULTS, - ...(snapshot || {}), - }; -} - -/** - * Failed launch records may retain a planned orbit in Launch Library, but - * must not be represented as a live or estimated payload in orbit. - * @param {string|null} status Normalized Launch Library status name. - * @returns {boolean} Whether orbital visualization is allowed. - */ -export function launchStatusAllowsOrbit(status) { - return !/\b(?:fail(?:ed|ure)?|partial failure)\b/i.test(String(status || '')); -} - -/** - * Describe only the mission paths that the selected record can actually show. - * Launch Library may retain a target orbit after a failure, but that target is - * not evidence of orbital insertion and cannot support a reconstructed replay. - * @param {object|null} launch Normalized launch record. - * @param {boolean} replayAvailable Whether a rendered ascent/orbit track exists. - * @returns {{orbit: string|null, ascent: string, replayAvailable: boolean}} - */ -export function missionPathPresentation(launch, replayAvailable = false) { - const orbitName = launch?.orbit?.name || (typeof launch?.orbit === 'string' ? launch.orbit : null); - const orbitAllowed = launchStatusAllowsOrbit(launch?.status); - const suppliedTrajectoryPoints = Array.isArray(launch?.trajectory) - ? launch.trajectory.filter((point) => ( - Number.isFinite(Number(point?.latitude)) - && Number.isFinite(Number(point?.longitude)) - )).length - : 0; - return { - orbit: orbitName ? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}` : null, - ascent: suppliedTrajectoryPoints > 1 - ? 'SUPPLIED TRAJECTORY POINTS' - : replayAvailable ? 'RECONSTRUCTED ESTIMATE' : 'UNAVAILABLE', - replayAvailable: Boolean(replayAvailable), - }; -} - -/** - * Decide whether one bounded rebuild is needed after the active TLE lookup. - * @param {object} input Retry state. - * @param {boolean} input.enabled Whether Space Missions is active. - * @param {number} input.retryCount Number of post-TLE retries already used. - * @param {string|null} input.activeTleText Resolved active TLE catalog. - * @param {string|null} input.renderedTleText TLE catalog used for the last build. - * @returns {boolean} Whether to schedule a refresh. - */ -export function shouldRetryAfterActiveTle({ - enabled, - retryCount, - activeTleText, - renderedTleText, -}) { - return Boolean( - enabled - && activeTleText - && activeTleText !== renderedTleText - && retryCount < MAX_POST_TLE_RETRIES, - ); -} - -/** - * Release aircraft follow state through each owning flight layer before a - * mission replay takes over the camera. - * @param {object|null} dataManager DataLayerManager instance. - * @returns {number} Number of owner APIs invoked. - */ -export function releaseAircraftTracking(dataManager) { - let released = 0; - for (const layerId of ['flights', 'military']) { - const module = dataManager?.layers?.get(layerId)?.module; - if (typeof module?.stopTracking !== 'function') continue; - module.stopTracking(); - released++; - } - return released; -} -const MISSION_ORBIT_PATTERN_GROUPS = 4; -const MISSION_ORBIT_DASHES_PER_GROUP = 100; -export const LAUNCH_PAD_ZONE_RADIUS_M = 500; -const LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M = 120000; -const LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M = 180000; - -const TRAJECTORY_STAGE_COLORS = [ - '#ff9f43', '#ff66c4', '#a78bfa', '#7bed9f', '#ffd166', '#60a5fa', -]; - -let _missionOrbitPatternRegistered = false; -const _pathDistanceCache = new WeakMap(); - -/** - * Register the selected-orbit tactical material once. Each group begins with - * one compact round dot followed by one hundred short dashes. - */ -function ensureMissionOrbitPatternRegistered() { - if (_missionOrbitPatternRegistered) return; - new Cesium.Material({ - fabric: { - type: 'GevMissionOrbitTactical', - uniforms: { - color: Cesium.Color.CYAN, - groupCount: MISSION_ORBIT_PATTERN_GROUPS, - dashCount: MISSION_ORBIT_DASHES_PER_GROUP, - }, - source: ` - czm_material czm_getMaterial(czm_materialInput materialInput) { - czm_material material = czm_getDefaultMaterial(materialInput); - float groupPosition = fract(materialInput.st.s * groupCount); - float markPosition = groupPosition * (dashCount + 1.0); - float markIndex = floor(markPosition); - float localPosition = fract(markPosition); - float centerDistance = abs(localPosition - 0.5); - float edge = max(fwidth(localPosition) * 1.35, 0.012); - float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); - float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5)); - float dash = dashAlong * dashAcross; - float dotAlong = (localPosition - 0.5) / 0.32; - float dotAcross = (materialInput.st.t - 0.5) / 0.5; - float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); - float isDot = 1.0 - step(0.5, markIndex); - float visible = mix(dash, dot, isDot); - material.diffuse = color.rgb; - material.emission = color.rgb * mix(0.07, 0.65, isDot); - material.alpha = color.a * visible * mix(0.58, 1.0, isDot); - return material; - }`, - }, - }); - _missionOrbitPatternRegistered = true; -} - -function createMissionOrbitPatternMaterial(color) { - ensureMissionOrbitPatternRegistered(); - return Cesium.Material.fromType('GevMissionOrbitTactical', { - color, - groupCount: MISSION_ORBIT_PATTERN_GROUPS, - dashCount: MISSION_ORBIT_DASHES_PER_GROUP, - }); -} - -function missionOrbitPrimitiveVisible(launchId) { - return Boolean( - _enabled - && _dataSource?.show - && (!_selectedLaunchId || _selectedLaunchId === launchId), - ); -} - -function syncMissionOrbitPrimitiveVisibility() { - for (const [launchId, path] of _missionOrbitPrimitives) { - if (path.primitive) path.primitive.show = missionOrbitPrimitiveVisible(launchId); - } -} - -function removeMissionOrbitPrimitives() { - for (const path of _missionOrbitPrimitives.values()) { - if (path.primitive && _viewer?.scene?.primitives) { - _viewer.scene.primitives.remove(path.primitive); - } - } - _missionOrbitPrimitives.clear(); -} - -function updateMissionOrbitPrimitiveFrames(nowDate) { - for (const [launchId, path] of _missionOrbitPrimitives) { - if (!path.primitive || !missionOrbitPrimitiveVisible(launchId)) continue; - orbitFrameModelMatrix(path.gmstAtBake, nowDate, path.primitive.modelMatrix); - if (path.labelBakePosition && path.labelPosition) { - Cesium.Matrix4.multiplyByPoint( - path.primitive.modelMatrix, - path.labelBakePosition, - path.labelPosition, - ); - } - } -} - -function addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack) { - if (!_viewer || !satelliteTrack || !Number.isFinite(satelliteTrack.gmstAtBake)) return false; - const collection = new Cesium.PolylineCollection(); - collection.add({ - positions: orbitPath, - width: 3, - material: createMissionOrbitPatternMaterial( - Cesium.Color.fromCssColorString('#22e6e6').withAlpha(0.95), - ), - }); - collection.show = missionOrbitPrimitiveVisible(launch.id); - orbitFrameModelMatrix(satelliteTrack.gmstAtBake, new Date(), collection.modelMatrix); - _viewer.scene.primitives.add(collection); - _missionOrbitPrimitives.set(launch.id, { - primitive: collection, - gmstAtBake: satelliteTrack.gmstAtBake, - }); - return true; -} - -function MissionOrbitPatternMaterialProperty(color) { - ensureMissionOrbitPatternRegistered(); - this._color = color; - this._definitionChanged = new Cesium.Event(); -} - -Object.defineProperties(MissionOrbitPatternMaterialProperty.prototype, { - isConstant: { get() { return true; } }, - definitionChanged: { get() { return this._definitionChanged; } }, -}); - -MissionOrbitPatternMaterialProperty.prototype.getType = function getType() { - return 'GevMissionOrbitTactical'; -}; - -MissionOrbitPatternMaterialProperty.prototype.getValue = function getValue(time, result) { - if (!Cesium.defined(result)) result = {}; - result.color = this._color; - result.groupCount = MISSION_ORBIT_PATTERN_GROUPS; - result.dashCount = MISSION_ORBIT_DASHES_PER_GROUP; - return result; -}; - -MissionOrbitPatternMaterialProperty.prototype.equals = function equals(other) { - return this === other; -}; - -/** - * Decide whether the selected launch-pad zone belongs in the current view. - * Both altitude and direct camera range are bounded so an oblique close-up can - * show the effect without leaking it into regional or globe views. - * @param {object} input Visibility inputs. - * @param {boolean} input.layerActive Whether Space Missions is active. - * @param {string|null} input.selectedLaunchId Selected mission identifier. - * @param {string} input.launchId Candidate mission identifier. - * @param {number} input.cameraHeightM Camera height above the ellipsoid. - * @param {number} input.cameraDistanceM Direct camera range to the launch pad. - * @returns {boolean} - */ -export function launchPadZoneVisible({ - layerActive, - selectedLaunchId, - launchId, - cameraHeightM, - cameraDistanceM, -}) { - return Boolean( - layerActive - && selectedLaunchId - && selectedLaunchId === launchId - && Number.isFinite(cameraHeightM) - && cameraHeightM <= LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M - && Number.isFinite(cameraDistanceM) - && cameraDistanceM <= LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M, - ); -} - -/** - * Resolve whether a surface mission anchor is safely on the camera-facing - * side of Earth. The small positive limb margin prevents labels anchored just - * beyond the horizon from leaking through the hidden Cesium globe. - * @param {Cesium.Cartesian3} cameraPosition Camera world position. - * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. - * @param {number} [limbMargin] Additional normalized horizon clearance. - * @returns {boolean} Whether the marker belongs on the visible hemisphere. - */ -export function missionAnchorHorizonVisible(cameraPosition, markerPosition, limbMargin = 0.012) { - if (!cameraPosition || !markerPosition) return false; - const cameraMagnitude = Cesium.Cartesian3.magnitude(cameraPosition); - if (!Number.isFinite(cameraMagnitude) || cameraMagnitude <= Cesium.Ellipsoid.WGS84.maximumRadius) { - return false; - } - const cameraDirection = Cesium.Cartesian3.normalize( - cameraPosition, - new Cesium.Cartesian3(), - ); - const markerDirection = Cesium.Cartesian3.normalize( - markerPosition, - new Cesium.Cartesian3(), - ); - const limbThreshold = Cesium.Ellipsoid.WGS84.maximumRadius / cameraMagnitude; - return Cesium.Cartesian3.dot(cameraDirection, markerDirection) - > limbThreshold + limbMargin; -} - -/** - * Resolve launch-anchor visibility for overview and selected-mission views. - * @param {Cesium.Cartesian3} cameraPosition Camera world position. - * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. - * @param {string} markerId Mission represented by this anchor. - * @param {string|null} selectedLaunchId Explicitly selected mission. - * @returns {boolean} Whether the launch anchor should render. - */ -export function missionAnchorVisible( - cameraPosition, - markerPosition, - markerId, - selectedLaunchId = null, -) { - if (selectedLaunchId && markerId !== selectedLaunchId) return false; - return missionAnchorHorizonVisible(cameraPosition, markerPosition); -} - -function focusFullGlobe(viewer, duration = 2.4) { - const canvas = viewer?.scene?.canvas; - const height = canvas?.clientHeight || canvas?.height; - const width = canvas?.clientWidth || canvas?.width; - const cartographic = viewer?.camera?.positionCartographic; - const fovy = viewer?.camera?.frustum?.fovy; - if (!(width > 0) || !(height > 0) || !cartographic || !Number.isFinite(fovy) || fovy <= 0 || fovy >= Math.PI) return; - const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; - const keyholeRadius = getKeyholeGeometry(width, height).radius; - const targetScreenRadius = keyholeRadius * 0.61; - const angularRadius = Math.atan((targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5)); - const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); - const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, altitude), - orientation: { heading: viewer.camera.heading, pitch: -Cesium.Math.PI_OVER_TWO, roll: 0 }, - duration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); -} - -function shortMissionLabel(name, maxLength = 24) { - const text = String(name || 'Unnamed mission').replace(/\s+/g, ' ').trim().split(' | ')[0]; - const compact = text.split(' — ')[0].trim(); - return compact.length > maxLength ? `${compact.slice(0, maxLength - 1).trimEnd()}…` : compact; -} - -/** - * Reduce generic launch-complex names to their identifying pad or area suffix. - * @param {string|null} launchSite Launch Library pad name. - * @returns {string|null} Compact launch-site identifier. - */ -export function compactLaunchSiteName(launchSite) { - const text = String(launchSite || '').replace(/\s+/g, ' ').trim(); - if (!text || /^(unknown|unavailable|n\/a)$/i.test(text)) return null; - const genericPrefix = /^(?:orbital\s+launch\s+pad|space\s+launch\s+complex|launch\s+(?:area|complex|pad|site))\s*[-·:]?\s*/i; - const compact = text.replace(genericPrefix, '').trim(); - return compact || null; -} - -/** - * Build the source-owned launch-site marker presentation. Overview markers - * compete in the bounded ambient-label domain; the selected mission moves to - * the protected selected lane and gains the former launch-site detail line. - * @param {object} launch Normalized Launch Library mission. - * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} position Existing display position. - * @param {boolean} [selected=false] Whether the mission owns the selected view. - * @returns {object} - */ -export function createRocketMissionMarkerOverlayEntry(launch, position, selected = false) { - const mission = shortMissionLabel(launch?.name, 26).toUpperCase(); - const siteName = compactLaunchSiteName(launch?.launchSite); - const launchTimeMs = Date.parse(launch?.launchTime); - const details = selected - ? [siteName - ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` - : 'LAUNCH SITE'] - : []; - return { - id: `launch:${launch?.id}`, - position, - variant: 'label', - title: mission, - details, - accent: '#22e6e6', - priority: selected - ? Number.MAX_SAFE_INTEGER - : Number.isFinite(launchTimeMs) ? Math.floor(launchTimeMs / 1000) : 0, - selected, - protected: selected, - paintLane: selected ? 'selected' : 'ambient-label', - collisionGroup: 'ambient-label', - interactive: false, - distanceScale: { - near: 1000, - nearValue: 1.08, - far: 20_000_000, - farValue: 0.78, - }, - gapPx: 12, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** - * Build one protected label belonging to the selected mission's trajectory, - * live payload position, or orbit. Copy is already source-formatted by the - * caller; newline semantics become explicit host detail rows. - * @param {object} input - * @param {string} input.id Stable mission-element identity. - * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} input.position Existing cached position. - * @param {string} input.text Former native-label text, including newlines. - * @param {string} input.accent Source-owned label color. - * @param {number} [input.priority=0] Protected placement order. - * @param {number} [input.gapPx=8] Anchor-to-label gap. - * @returns {object} - */ -export function createRocketMissionElementOverlayEntry({ - id, - position, - text, - accent, - priority = 0, - gapPx = 8, -}) { - const [title, ...details] = String(text || '').split('\n'); - return { - id: String(id), - position, - variant: 'label', - title, - details, - accent, - priority, - selected: true, - protected: true, - paintLane: 'selected', - collisionGroup: 'ambient-label', - interactive: false, - gapPx, - verticalOnly: true, - placement: 'above', - edgeFade: 'keyhole', - horizonCull: true, - terrainOcclusion: false, - }; -} - -/** Keep the newest ambient mission markers with stable identity tie-breaking. */ -export function selectRocketMissionMarkerOverlayCohort( - entries, - limit = ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, -) { - const cap = Math.max(0, Math.min( - ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, - Math.floor(Number(limit) || 0), - )); - if (!Array.isArray(entries) || cap === 0) return []; - return entries.slice().sort((a, b) => ( - b.priority - a.priority || String(a.id).localeCompare(String(b.id)) - )).slice(0, cap); -} - -function missionHoverReticleImage() { - if (_missionHoverReticleImage) return _missionHoverReticleImage; - const canvas = document.createElement('canvas'); - const size = 48; - const inset = 6; - const arm = 12; - canvas.width = size; - canvas.height = size; - const context = canvas.getContext('2d'); - context.strokeStyle = '#22e6e6'; - context.lineWidth = 2; - context.lineCap = 'square'; - context.shadowColor = 'rgba(34, 230, 230, .72)'; - context.shadowBlur = 5; - context.beginPath(); - context.moveTo(inset, inset + arm); - context.lineTo(inset, inset); - context.lineTo(inset + arm, inset); - context.moveTo(size - inset - arm, inset); - context.lineTo(size - inset, inset); - context.lineTo(size - inset, inset + arm); - context.moveTo(inset, size - inset - arm); - context.lineTo(inset, size - inset); - context.lineTo(inset + arm, size - inset); - context.moveTo(size - inset - arm, size - inset); - context.lineTo(size - inset, size - inset); - context.lineTo(size - inset, size - inset - arm); - context.stroke(); - _missionHoverReticleImage = canvas; - return canvas; -} - -/** - * Resolve the one permitted screen-space replay overlay state. - * @param {object} input Overlay state. - * @param {boolean} input.replayActive Whether replay owns the camera. - * @param {boolean} input.ascending Whether the replay marker is on ascent. - * @param {boolean} input.countdownActive Whether the T-minus hold is active. - * @returns {'countdown'|'ascent'|'orbit'|null} - */ -export function replayOverlayMode({ - replayActive, - ascending, - countdownActive, - preCountdownActive = false, -}) { - if (!replayActive) return null; - if (preCountdownActive) return null; - if (countdownActive) return 'countdown'; - return ascending ? 'ascent' : 'orbit'; -} - -/** - * Rotate an upright screen-space rocket so its nose follows a projected path. - * @param {{x: number, y: number}} from Current screen point. - * @param {{x: number, y: number}} to Forward screen point. - * @returns {number} Clockwise CSS rotation in radians. - */ -export function replayVehicleScreenRotation(from, to) { - const dx = Number(to?.x) - Number(from?.x); - const dy = Number(to?.y) - Number(from?.y); - if (!Number.isFinite(dx) || !Number.isFinite(dy) || Math.hypot(dx, dy) < 0.01) return 0; - return Math.atan2(dx, -dy); -} - -/** - * Reduce small screen-space reprojection jitter without allowing the marker - * to lag behind a camera jump or a phase transition. - * @param {{x:number,y:number}|null} previous Previous rendered position. - * @param {{x:number,y:number}} next Current path projection. - * @param {number} [alpha] Interpolation amount. - * @param {number} [snapDistance] Maximum distance to smooth. - * @returns {{x:number,y:number}} - */ -export function smoothReplayWindowPosition( - previous, - next, - alpha = 0.55, - snapDistance = 24, -) { - if (!previous || !next) return next; - const distance = Math.hypot(next.x - previous.x, next.y - previous.y); - if (!Number.isFinite(distance) || distance > snapDistance) return next; - const amount = Cesium.Math.clamp(Number(alpha) || 0, 0, 1); - return { - x: Cesium.Math.lerp(previous.x, next.x, amount), - y: Cesium.Math.lerp(previous.y, next.y, amount), - }; -} - -function createReplayVehicleOverlay() { - if (_replayVehicleOverlay || typeof document === 'undefined') return; - const host = document.getElementById('cesiumContainer') || document.body; - _replayVehicleOverlay = document.createElement('div'); - _replayVehicleOverlay.className = 'mission-replay-vehicle-overlay'; - _replayVehicleOverlay.hidden = true; - _replayVehicleOverlay.setAttribute('aria-hidden', 'true'); - _replayVehicleOverlay.innerHTML = ` -
- - -
- -
- - -
`; - host.appendChild(_replayVehicleOverlay); -} - -function hideReplayVehicleOverlay() { - if (!_replayVehicleOverlay) return; - _replayVehicleOverlay.hidden = true; - _replayVehicleOverlay.classList.remove('is-thrusting', 'is-paused'); -} - -function destroyReplayVehicleOverlay() { - _replayVehicleOverlay?.remove(); - _replayVehicleOverlay = null; - _replayVehicleOverlayText = ''; -} - -/** - * Score the amount of useful mission context available for roster triage. - * @param {object} launch Normalized launch record. - * @returns {number} Completeness score. - */ -export function missionDataCompleteness(launch = {}) { - let score = 0; - const present = (value) => value !== null && value !== undefined && value !== ''; - score += present(launch.provider) ? 1 : 0; - score += present(launch.mission) ? 2 : 0; - score += present(launch.missionName) ? 1 : 0; - score += present(launch.orbit?.name || launch.orbit) ? 2 : 0; - score += Array.isArray(launch.payloads) ? Math.min(launch.payloads.length, 5) * 2 : 0; - score += Array.isArray(launch.recoveryStages) ? Math.min(launch.recoveryStages.length, 4) * 2 : 0; - score += Array.isArray(launch.trajectory) ? Math.min(launch.trajectory.length, 12) : 0; - score += Array.isArray(launch.timeline) ? Math.min(launch.timeline.length, 6) : 0; - return score; -} - -/** - * Build a data-rich roster while preserving the source-array index - * used by mission selection and Previous/Next navigation. - * @param {Array} launches Normalized launch records. - * @returns {Array<{launch: object, index: number}>} - */ -export function missionRosterEntries(launches) { - return (launches || []) - .map((launch, index) => ({ launch, index })) - .sort((a, b) => { - const completeness = missionDataCompleteness(b.launch) - missionDataCompleteness(a.launch); - if (completeness !== 0) return completeness; - const aTime = Date.parse(a.launch?.launchTime); - const bTime = Date.parse(b.launch?.launchTime); - if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime; - return b.index - a.index; - }); -} - -/** - * Preserve the user's globe scale for roster previews while avoiding an - * accidental surface-level fly-to when the list is opened from a close view. - * @param {number} cameraHeight Current camera height above the ellipsoid. - * @returns {number} Preview range in metres. - */ -export function missionHoverPreviewRange(cameraHeight) { - const height = Number(cameraHeight); - return Math.max( - MISSION_CLOSE_VIEW_RANGE_M, - Number.isFinite(height) ? height : MISSION_GLOBE_VIEW_RANGE_M, - ); -} - -/** - * Coordinate pointer and keyboard ownership of the temporary roster preview. - * The most recent input owns the preview until it leaves, then any remaining - * input resumes ownership. This lets keyboard focus take over from a pointer - * resting on another row without losing that pointer preview on blur. - * @param {{preview: (index: number) => void, clear: () => void}} handlers - * @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void, - * focus: (index: number) => void, blur: (nextIndex?: number|null) => void, - * reset: () => void}} - */ -export function createMissionRosterPreviewOwnership({ preview, clear }) { - let pointerIndex = null; - let focusIndex = null; - let latestOwner = null; - let activeIndex = null; - - const sync = () => { - const nextIndex = latestOwner === 'pointer' - ? pointerIndex ?? focusIndex - : focusIndex ?? pointerIndex; - if (nextIndex === activeIndex) return; - activeIndex = nextIndex; - if (Number.isInteger(nextIndex)) preview(nextIndex); - else clear(); - }; - - return { - pointerEnter(index) { - pointerIndex = index; - latestOwner = 'pointer'; - sync(); - }, - pointerLeave() { - pointerIndex = null; - if (latestOwner === 'pointer') latestOwner = focusIndex === null ? null : 'focus'; - sync(); - }, - focus(index) { - focusIndex = index; - latestOwner = 'focus'; - sync(); - }, - blur(nextIndex = null) { - focusIndex = Number.isInteger(nextIndex) ? nextIndex : null; - if (latestOwner === 'focus') latestOwner = focusIndex === null - ? (pointerIndex === null ? null : 'pointer') - : 'focus'; - sync(); - }, - reset() { - pointerIndex = null; - focusIndex = null; - latestOwner = null; - activeIndex = null; - clear(); - }, - }; -} - -/** - * Bind native keyboard focus events for one rendered roster row. - * @param {Element} button Mission roster button. - * @param {number} index Source-array mission index. - * @param {{pointerEnter: (index: number) => void, pointerLeave: () => void, - * focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership - * @param {Element} roster Roster root used to reject focus targets outside the list. - */ -export function bindMissionRosterItemKeyboardPreview(button, index, ownership, roster) { - button.addEventListener('mouseenter', () => ownership.pointerEnter(index)); - button.addEventListener('mouseleave', () => ownership.pointerLeave()); - button.addEventListener('focus', () => ownership.focus(index)); - button.addEventListener('blur', (event) => { - const nextButton = event.relatedTarget?.closest?.('[data-mission-roster-index]'); - const nextIndex = nextButton && roster.contains(nextButton) - ? Number(nextButton.dataset.missionRosterIndex) - : null; - ownership.blur(nextIndex); - }); -} - -/** Capture the exact mission identity owned by keyboard focus before a refresh. */ -export function captureMissionRosterFocus(list, activeElement = globalThis.document?.activeElement) { - const button = activeElement?.closest?.('[data-mission-roster-id]'); - if (!button || !list?.contains(button)) return null; - const launchId = button.dataset.missionRosterId; - return launchId ? { launchId } : null; -} - -/** Restore focus by mission identity, or continue after the list if it departed. */ -export function restoreMissionRosterFocus(list, snapshot, continuation) { - if (!snapshot?.launchId) return 'none'; - const button = Array.from(list?.querySelectorAll?.('[data-mission-roster-id]') || []) - .find((candidate) => candidate.dataset.missionRosterId === snapshot.launchId); - if (button) { - button.focus({ preventScroll: true }); - return 'restored'; - } - if (continuation?.focus) { - continuation.focus({ preventScroll: true }); - return 'continued'; - } - return 'none'; -} - -/** Resolve a pending preview against the current refresh, never a stale object. */ -export function resolveMissionRosterPreviewLaunch(launches, launchId) { - return (launches || []).find((candidate) => candidate.id === launchId) || null; -} - -/** - * Format a mission epoch for compact on-globe replay labels. - * @param {string|Date|null} launchTime ISO-8601 mission time or Date. - * @returns {string} UTC timestamp or a clear unavailable state. - */ -export function formatMissionEventTime(launchTime) { - const date = new Date(launchTime); - if (!launchTime || !Number.isFinite(date.getTime())) return 'UNAVAILABLE'; - return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`; -} - -/** - * Parse the ISO-8601 durations supplied by Launch Library timeline events. - * @param {string|null} value ISO duration such as PT8M40S or -PT35M. - * @returns {number|null} Signed duration in seconds. - */ -export function parseMissionDurationSeconds(value) { - const match = String(value || '').trim().match( - /^(-)?P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/, - ); - if (!match) return null; - const seconds = (Number(match[2] || 0) * 86400) - + (Number(match[3] || 0) * 3600) - + (Number(match[4] || 0) * 60) - + Number(match[5] || 0); - return match[1] ? -seconds : seconds; -} - -function orbitInsertionOffsetSeconds(launch) { - const events = (launch.timeline || []).filter((event) => Number.isFinite(event.offsetSeconds) && event.offsetSeconds >= 0); - if (!events.length) return null; - const deployment = events.filter((event) => /deploy|payload separation|spacecraft separation|orbit insertion|injection/i.test(event.name)); - if (deployment.length) return Math.max(...deployment.map((event) => event.offsetSeconds)); - const engineCutoff = events.filter((event) => /seco|second engine cutoff/i.test(event.name)); - if (engineCutoff.length) return Math.max(...engineCutoff.map((event) => event.offsetSeconds)); - return Math.max(...events.map((event) => event.offsetSeconds)); -} - -function estimatedOrbitPeriodSeconds(orbitPath) { - if (!orbitPath?.length) return 5400; - const meanRadius = orbitPath.reduce((total, point) => total + Cesium.Cartesian3.magnitude(point), 0) / orbitPath.length; - return 2 * Math.PI * Math.sqrt((meanRadius ** 3) / 3.986004418e14); -} - -/** - * Derive a compressed but mission-specific ascent replay duration. - * Launch Library timelines are authoritative when they expose insertion, - * SECO, or separation timing. Sparse records fall back to the reconstructed - * path length and a conservative ascent velocity estimate. - * @param {object} launch Normalized launch record. - * @param {Cesium.Cartesian3[]} ascentPath Reconstructed or supplied ascent. - * @returns {number} Replay duration in seconds. - */ -export function replayAscentDurationSeconds(launch, ascentPath = []) { - const disclosedSeconds = orbitInsertionOffsetSeconds(launch); - let realAscentSeconds = disclosedSeconds > 0 ? disclosedSeconds : null; - if (!realAscentSeconds && ascentPath.length > 1) { - const pathLength = ascentPath.slice(1).reduce( - (total, point, index) => total + Cesium.Cartesian3.distance(ascentPath[index], point), - 0, - ); - realAscentSeconds = Math.max(180, pathLength / 9000); - } - if (!realAscentSeconds) realAscentSeconds = REPLAY_ASCENT_FALLBACK_SEC * 50; - return Cesium.Math.clamp( - REPLAY_ASCENT_FALLBACK_SEC * (realAscentSeconds / 600), - REPLAY_ASCENT_MIN_SEC, - REPLAY_ASCENT_MAX_SEC, - ); -} - -/** - * Clamp and snap a replay speed multiplier to the supported slider range. - * @param {number|string} value Requested playback multiplier. - * @returns {number} Supported multiplier between 0.25x and 4x. - */ -export function normalizeReplaySpeed(value) { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return 1; - const clamped = Cesium.Math.clamp(numeric, REPLAY_SPEED_MIN, REPLAY_SPEED_MAX); - return Math.round(clamped / REPLAY_SPEED_STEP) * REPLAY_SPEED_STEP; -} - -/** - * Preserve replay elapsed time when resuming after a pause. - * @param {number} startedAt Original replay start epoch in milliseconds. - * @param {number} pausedAt Pause epoch in milliseconds. - * @param {number} resumedAt Resume epoch in milliseconds. - * @returns {number} Shifted start epoch. - */ -export function replayStartAfterPause(startedAt, pausedAt, resumedAt) { - if (![startedAt, pausedAt, resumedAt].every(Number.isFinite)) return startedAt; - return startedAt + Math.max(0, resumedAt - pausedAt); -} - -export function replayState( - launch, - startedAt, - ascentDurationSec, - orbitDurationSec, - orbitPeriodSec, - speed = 1, - nowMs = Date.now(), - preCountdownDurationSec = 0, - loop = true, -) { - const animationDurationSec = ascentDurationSec + orbitDurationSec; - const realSecondsSinceStart = (nowMs - startedAt) / 1000; - const preCountdownDuration = Math.max(0, Number(preCountdownDurationSec) || 0); - const preCountdownActive = preCountdownDuration > 0 - && realSecondsSinceStart < -preCountdownDuration; - const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive; - const countdownSeconds = countdownActive ? Math.ceil(-realSecondsSinceStart) : 0; - const elapsedSinceStart = Math.max(0, realSecondsSinceStart * normalizeReplaySpeed(speed)); - const elapsed = loop - ? elapsedSinceStart % animationDurationSec - : Math.min(elapsedSinceStart, Math.max(0, animationDurationSec - 1e-6)); - const insertionOffsetSec = orbitInsertionOffsetSeconds(launch); - const ascending = elapsed < ascentDurationSec; - const phaseProgress = ascending - ? elapsed / ascentDurationSec - : (elapsed - ascentDurationSec) / orbitDurationSec; - const missionOffsetSec = insertionOffsetSec === null - ? null - : ascending - ? phaseProgress * insertionOffsetSec - : insertionOffsetSec + phaseProgress * orbitPeriodSec; - const launchEpoch = Date.parse(launch.launchTime); - const eventTime = Number.isFinite(launchEpoch) && missionOffsetSec !== null - ? new Date(launchEpoch + missionOffsetSec * 1000) - : null; - return { - ascending, - phaseProgress, - eventTime, - elapsedSinceStart, - countdownActive, - preCountdownActive, - countdownSeconds, - }; -} - -export function approximateOrbitPath(launch) { - if (!launch.orbit?.name || !Number.isFinite(launch.lat) || !Number.isFinite(launch.lon)) return null; - const orbitName = launch.orbit.name.toLowerCase(); - const altitude = orbitName.includes('geostationary') || orbitName.includes('transfer') ? 35786000 - : orbitName.includes('medium') ? 20200000 : 550000; - const radius = Cesium.Ellipsoid.WGS84.maximumRadius + altitude; - const longitude = Cesium.Math.toRadians(launch.lon); - const latitude = Cesium.Math.toRadians(launch.lat); - const up = new Cesium.Cartesian3( - Math.cos(latitude) * Math.cos(longitude), - Math.cos(latitude) * Math.sin(longitude), - Math.sin(latitude), - ); - const east = new Cesium.Cartesian3( - -Math.sin(longitude), - Math.cos(longitude), - 0, - ); - const north = new Cesium.Cartesian3( - -Math.sin(latitude) * Math.cos(longitude), - -Math.sin(latitude) * Math.sin(longitude), - Math.cos(latitude), - ); - const isPolar = orbitName.includes('polar') || orbitName.includes('sun'); - const isWesternNorthAmerica = launch.lat > 20 && launch.lat < 60 - && launch.lon > -140 && launch.lon < -105; - const launchAzimuthDeg = isPolar - ? (launch.lat >= 0 ? 180 : 0) - : isWesternNorthAmerica ? 190 : 90; - const launchAzimuth = Cesium.Math.toRadians(launchAzimuthDeg); - const forward = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, Math.cos(launchAzimuth), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, Math.sin(launchAzimuth), new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - // A reconstructed orbit is an estimate, not a claim that the vehicle was - // inserted directly above the pad. Offset the orbital plane downrange by a - // small launch-to-insertion arc so a top-down view does not draw the entire - // orbit on top of the launch site. The ascent still joins this ring at its - // propagated insertion reference. - const insertionArc = Cesium.Math.toRadians(isPolar ? 8 : 12); - const orbitAnchor = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(up, Math.cos(insertionArc), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(forward, Math.sin(insertionArc), new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - const planeNormal = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(orbitAnchor, forward, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const crossTrack = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(planeNormal, orbitAnchor, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - return Array.from({ length: 97 }, (_, index) => { - const angle = (index / 96) * Math.PI * 2; - return new Cesium.Cartesian3( - radius * (Math.cos(angle) * orbitAnchor.x + Math.sin(angle) * crossTrack.x), - radius * (Math.cos(angle) * orbitAnchor.y + Math.sin(angle) * crossTrack.y), - radius * (Math.cos(angle) * orbitAnchor.z + Math.sin(angle) * crossTrack.z), - ); - }); -} - -function entityLaunchId(entity) { - if (!entity?.id || typeof entity.id !== 'string') return null; - const match = entity.id.match(/^rocket-[^:]+:([^:]+)/); - return match?.[1] || null; -} - -function clearMissionOverlaySources() { - _selectedMissionOverlayTimeText = null; - _missionOverlayHost.clearSource(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false); - _missionOverlayHost.clearSource(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false); -} - -function syncMissionOverlayEntries() { - if (!_enabled || !_dataSource?.show) { - clearMissionOverlaySources(); - return; - } - const selectedRecord = _selectedLaunchId - ? _missionOverlayRecords.get(_selectedLaunchId) - : null; - if (selectedRecord) { - _missionOverlayHost.clearSource(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false); - const entries = selectedRecord.elementEntryFactories.map((createEntry) => createEntry()); - if (_replayCameraLaunchId !== selectedRecord.launch.id) { - entries.unshift(createRocketMissionMarkerOverlayEntry( - selectedRecord.launch, - selectedRecord.anchorPosition, - true, - )); - } - _missionOverlayHost.setEntries( - ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, - entries, - ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, - ); - _missionOverlayHost.setVisible(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, true); - _selectedMissionOverlayTimeText = selectedRecord.liveEventTime - ? formatMissionEventTime(selectedRecord.liveEventTime()) - : null; - return; - } - - _missionOverlayHost.clearSource(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID); - _missionOverlayHost.setVisible(ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false); - _selectedMissionOverlayTimeText = null; - const entries = selectRocketMissionMarkerOverlayCohort( - Array.from(_missionOverlayRecords.values(), (record) => { - const entry = createRocketMissionMarkerOverlayEntry( - record.launch, - record.anchorPosition, - ); - if (record.launch.id === _hoveredRosterLaunchId) { - entry.pinned = true; - entry.priority = Number.MAX_SAFE_INTEGER - 1; - } - return entry; - }), - ); - _missionOverlayHost.setEntries( - ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, - entries, - ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS, - ); - _missionOverlayHost.setVisible(ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, true); -} - -function refreshSelectedMissionOverlayText() { - const selectedRecord = _selectedLaunchId - ? _missionOverlayRecords.get(_selectedLaunchId) - : null; - if (!selectedRecord?.liveEventTime) return; - const nextText = formatMissionEventTime(selectedRecord.liveEventTime()); - if (nextText !== _selectedMissionOverlayTimeText) syncMissionOverlayEntries(); -} - -function setSelectedMission(launchId, isolate = true) { - if (launchId) clearMissionRosterHover(); - if (_replayCameraLaunchId && _replayCameraLaunchId !== launchId) stopMissionReplay(); - if (_missionZoomAnchorId && _missionZoomAnchorId !== launchId) stopMissionZoomAnchor(); - _selectedLaunchId = launchId; - _explicitSelection = Boolean(launchId && isolate); - if (launchId) _animationStarts.set(launchId, Date.now()); - if (!launchId && _viewer) { - stopMissionZoomAnchor(); - _viewer.selectedEntity = undefined; - } - if (!_dataSource) return; - for (const entity of _dataSource.entities.values) { - const relatedId = entityLaunchId(entity); - entity.show = launchId - ? relatedId === launchId - : entity.id.startsWith('rocket-launch:'); - } - syncMissionOrbitPrimitiveVisibility(); - syncMissionOverlayEntries(); - renderMissionPanel(); -} - -function hideLaunchPadZone() { - if (_launchPadZonePrimitive) _launchPadZonePrimitive.show = false; -} - -function removeLaunchPadZonePrimitive() { - if (_launchPadZonePrimitive && _viewer?.scene?.primitives) { - _viewer.scene.primitives.remove(_launchPadZonePrimitive); - } - _launchPadZonePrimitive = null; - _launchPadZoneLaunchId = null; -} - -function createLaunchPadZonePrimitive(launch) { - removeLaunchPadZonePrimitive(); - const material = new Cesium.Material({ - fabric: { - type: 'GevLaunchPadZone', - uniforms: { - color: Cesium.Color.fromCssColorString('#22e6e6'), - fillAlpha: 0.105, - rimAlpha: 0.72, - }, - source: ` - czm_material czm_getMaterial(czm_materialInput materialInput) { - czm_material material = czm_getDefaultMaterial(materialInput); - vec2 centered = (materialInput.st - vec2(0.5)) * 2.0; - float radius = length(centered); - float inside = 1.0 - smoothstep(0.985, 1.0, radius); - float rim = smoothstep(0.952, 0.985, radius) * inside; - material.diffuse = color.rgb; - material.emission = color.rgb * rim * 0.35; - material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim); - return material; - }`, - }, - }); - const geometry = new Cesium.EllipseGeometry({ - center: Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat), - semiMajorAxis: LAUNCH_PAD_ZONE_RADIUS_M, - semiMinorAxis: LAUNCH_PAD_ZONE_RADIUS_M, - granularity: Cesium.Math.toRadians(0.08), - vertexFormat: Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, - }); - _launchPadZonePrimitive = _viewer.scene.primitives.add(new Cesium.GroundPrimitive({ - geometryInstances: new Cesium.GeometryInstance({ - geometry, - id: `rocket-pad-zone:${launch.id}`, - }), - appearance: new Cesium.MaterialAppearance({ - material, - translucent: true, - closed: false, - faceForward: true, - flat: true, - // Keep the zone classified onto the photoreal surface, but bias only its - // rasterized depth toward the camera. This avoids coplanar fragments - // being intermittently buried by the launch-pad mesh at oblique angles - // without adding a world-space height that would make the ring float. - renderState: { - depthTest: { - enabled: true, - }, - depthMask: false, - polygonOffset: { - enabled: true, - factor: -1, - units: -4, - }, - blending: Cesium.BlendingState.ALPHA_BLEND, - }, - }), - classificationType: Cesium.ClassificationType.BOTH, - asynchronous: true, - show: true, - })); - _launchPadZoneLaunchId = launch.id; -} - -function initLaunchPadZonePrimitive() { - if (!_viewer || _launchPadZoneRemover) return; - _launchPadZoneRemover = _viewer.scene.preRender.addEventListener(() => { - if (!_enabled || !_dataSource?.show) { - hideLaunchPadZone(); - return; - } - const nowMs = Date.now(); - if (nowMs - _lastMissionRingRotationMs >= 1000) { - _missionRingDate.setTime(nowMs); - updateMissionOrbitPrimitiveFrames(_missionRingDate); - _lastMissionRingRotationMs = nowMs; - } - const launch = _launches.find((item) => item.id === _selectedLaunchId); - const camera = _viewer?.camera; - if (!launch || !camera) { - hideLaunchPadZone(); - return; - } - const launchPosition = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - const visible = launchPadZoneVisible({ - layerActive: Boolean(_dataSource?.show), - selectedLaunchId: _selectedLaunchId, - launchId: launch.id, - cameraHeightM: camera.positionCartographic?.height, - cameraDistanceM: Cesium.Cartesian3.distance(camera.positionWC, launchPosition), - }); - if (!visible) { - hideLaunchPadZone(); - return; - } - if (_launchPadZoneLaunchId !== launch.id) createLaunchPadZonePrimitive(launch); - _launchPadZonePrimitive.show = true; - }); -} - -function destroyLaunchPadZonePrimitive() { - if (_launchPadZoneRemover) _launchPadZoneRemover(); - _launchPadZoneRemover = null; - removeLaunchPadZonePrimitive(); -} - -export function samplePath(path, progress, result) { - if (!path?.length) return undefined; - // Degenerate returns clone into `result` when provided — handing back a - // path vertex would let an in-place caller mutate the path geometry. - if (path.length === 1) return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; - let distances = _pathDistanceCache.get(path); - if (!distances) { - distances = new Float64Array(path.length); - for (let index = 1; index < path.length; index++) { - distances[index] = distances[index - 1] - + Cesium.Cartesian3.distance(path[index - 1], path[index]); - } - _pathDistanceCache.set(path, distances); - } - const totalDistance = distances.at(-1); - if (!(totalDistance > 0)) return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; - const targetDistance = Cesium.Math.clamp(progress, 0, 1) * totalDistance; - let low = 1; - let high = distances.length - 1; - while (low < high) { - const middle = Math.floor((low + high) / 2); - if (distances[middle] < targetDistance) low = middle + 1; - else high = middle; - } - const index = Math.max(0, low - 1); - const segmentDistance = distances[index + 1] - distances[index]; - const segmentProgress = segmentDistance > 0 - ? (targetDistance - distances[index]) / segmentDistance - : 0; - return Cesium.Cartesian3.lerp( - path[index], - path[index + 1], - segmentProgress, - result || new Cesium.Cartesian3(), - ); -} - -/** - * Resolve a continuously advancing orbit fraction from a wall-clock epoch. - * Fractional seconds prevent the selected satellite marker from jumping at - * each second boundary and forcing a one-frame photoreal globe LOD pulse. - * @param {number} nowMs Wall-clock epoch in milliseconds. - * @param {number} periodSec Orbital period in seconds. - * @returns {number} Normalized progress in the range [0, 1). - */ -export function orbitProgressAtTime(nowMs, periodSec) { - const period = Math.max(1, Number(periodSec) || 1); - return Cesium.Math.mod((Number(nowMs) || 0) / 1000, period) / period; -} - -/** - * Construct one continuous estimated climb from the pad to orbit insertion. - * Horizontal movement begins much more slowly than altitude gain, preserving - * the near-vertical launch appearance without a hard corner at 120 km. - * @param {Cesium.Cartesian3} launchPosition Launch-pad position. - * @param {Cesium.Cartesian3} insertionPosition Orbit insertion position. - * @param {number} [samples] Number of curve intervals. - * @returns {Cesium.Cartesian3[]} - */ -export function reconstructedAscentPath(launchPosition, insertionPosition, samples = 512) { - const ellipsoid = Cesium.Ellipsoid.WGS84; - const origin = ellipsoid.cartesianToCartographic(launchPosition); - const insertion = ellipsoid.cartesianToCartographic(insertionPosition); - if (!origin || !insertion) return [launchPosition, insertionPosition]; - const geodesic = new Cesium.EllipsoidGeodesic( - new Cesium.Cartographic(origin.longitude, origin.latitude), - new Cesium.Cartographic(insertion.longitude, insertion.latitude), - ellipsoid, - ); - return Array.from({ length: samples + 1 }, (_, index) => { - const progress = index / samples; - if (index === 0) return launchPosition; - if (index === samples) return insertionPosition; - const horizontalProgress = progress ** 4; - const cartographic = geodesic.interpolateUsingFraction( - horizontalProgress, - new Cesium.Cartographic(), - ); - // Approximate the inertial eastward lead accumulated during a ten-minute - // ascent. The p³ envelope keeps liftoff nearly vertical, peaks during the - // upper climb, and returns to the fixed insertion endpoint. - const rotationalLead = EARTH_ROTATION_RAD_PER_SEC - * PROJECTED_ASCENT_ROTATION_SEC - * Math.sin(Math.PI * progress ** 3); - cartographic.longitude = Cesium.Math.negativePiToPi( - cartographic.longitude + rotationalLead, - ); - cartographic.height = Cesium.Math.lerp( - Math.max(0, origin.height), - Math.max(0, insertion.height), - Math.sin(progress * Cesium.Math.PI_OVER_TWO), - ); - return ellipsoid.cartographicToCartesian(cartographic); - }); -} - -function nearestOrbitIndex(orbitPath, referencePosition) { - if (!orbitPath?.length || !referencePosition) return 0; - const referenceDirection = Cesium.Cartesian3.normalize(referencePosition, new Cesium.Cartesian3()); - let bestIndex = 0; - let bestDot = -Number.MAX_VALUE; - orbitPath.forEach((candidate, index) => { - const direction = Cesium.Cartesian3.normalize(candidate, new Cesium.Cartesian3()); - const dot = Cesium.Cartesian3.dot(referenceDirection, direction); - if (dot > bestDot) { - bestDot = dot; - bestIndex = index; - } - }); - return bestIndex; -} - -function orbitPathFromInsertion(orbitPath, insertionIndex) { - if (!orbitPath?.length) return []; - const first = orbitPath[0]; - const last = orbitPath.at(-1); - const isClosed = orbitPath.length > 2 && Cesium.Cartesian3.distance(first, last) < 1000; - const core = isClosed ? orbitPath.slice(0, -1) : orbitPath.slice(); - if (!core.length) return orbitPath.slice(); - const index = Cesium.Math.mod(insertionIndex, core.length); - const rotated = [...core.slice(index), ...core.slice(0, index)]; - rotated.push(rotated[0]); - return rotated; -} - -function surfaceSafeSegment(startPosition, endPosition) { - const ellipsoid = Cesium.Ellipsoid.WGS84; - const start = ellipsoid.cartesianToCartographic(startPosition); - const end = ellipsoid.cartesianToCartographic(endPosition); - if (!start || !end) return [startPosition, endPosition]; - const geodesic = new Cesium.EllipsoidGeodesic( - new Cesium.Cartographic(start.longitude, start.latitude), - new Cesium.Cartographic(end.longitude, end.latitude), - ellipsoid, - ); - // Replay advances in real time across this path. Keep the geometry - // surface-safe, but give the animated marker and chase camera enough - // samples that they do not visibly pause at long segment boundaries. - const steps = Cesium.Math.clamp(Math.ceil(geodesic.surfaceDistance / 75000), 2, 256); - const positions = []; - for (let index = 0; index <= steps; index++) { - const fraction = index / steps; - const eased = fraction * fraction * (3 - 2 * fraction); - const cartographic = geodesic.interpolateUsingFraction(fraction, new Cesium.Cartographic()); - cartographic.height = Cesium.Math.lerp(start.height, end.height, eased); - positions.push(ellipsoid.cartographicToCartesian(cartographic)); - } - positions[0] = startPosition; - positions[positions.length - 1] = endPosition; - return positions; -} - -function surfaceSafePath(controlPositions) { - if (!controlPositions?.length) return []; - if (controlPositions.length === 1) return controlPositions.slice(); - const path = []; - for (let index = 0; index < controlPositions.length - 1; index++) { - const segment = surfaceSafeSegment(controlPositions[index], controlPositions[index + 1]); - path.push(...(index === 0 ? segment : segment.slice(1))); - } - return path; -} - -function blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex) { - if (!ascentPath?.length || ascentPath.length < 4 || !orbitPath?.length) return ascentPath; - const ellipsoid = Cesium.Ellipsoid.WGS84; - const transferEnd = orbitPath[insertionIndex]; - const nextOrbit = orbitPath[(insertionIndex + 1) % orbitPath.length]; - const orbitTangent = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(nextOrbit, transferEnd, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.equalsEpsilon(orbitTangent, Cesium.Cartesian3.ZERO, 1e-8)) return ascentPath; - - // Replace a substantial final section with one cubic Bézier transition. - // Matching both endpoint tangents avoids the short corrective hook produced - // by locally pulling only the last few ascent samples toward the orbit. - const blendCount = Math.min(52, ascentPath.length - 2); - const blendStart = ascentPath.length - 1 - blendCount; - const start = ascentPath[blendStart]; - const previous = ascentPath[Math.max(0, blendStart - 1)]; - const altitudeEnvelope = ascentPath.slice(blendStart).map((position) => Math.max( - 0, - ellipsoid.cartesianToCartographic(position)?.height || 0, - )); - const ascentTangent = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(start, previous, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const chordLength = Math.max(Cesium.Cartesian3.distance(start, transferEnd), 1000); - const handleLength = chordLength * 0.28; - const controlA = Cesium.Cartesian3.add( - start, - Cesium.Cartesian3.multiplyByScalar(ascentTangent, handleLength, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const controlB = Cesium.Cartesian3.add( - transferEnd, - Cesium.Cartesian3.multiplyByScalar(orbitTangent, -handleLength, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - for (let index = 0; index <= blendCount; index++) { - const progress = index / blendCount; - const inverse = 1 - progress; - const point = new Cesium.Cartesian3(); - Cesium.Cartesian3.multiplyByScalar(start, inverse ** 3, point); - Cesium.Cartesian3.add( - point, - Cesium.Cartesian3.multiplyByScalar(controlA, 3 * inverse ** 2 * progress, new Cesium.Cartesian3()), - point, - ); - Cesium.Cartesian3.add( - point, - Cesium.Cartesian3.multiplyByScalar(controlB, 3 * inverse * progress ** 2, new Cesium.Cartesian3()), - point, - ); - Cesium.Cartesian3.add( - point, - Cesium.Cartesian3.multiplyByScalar(transferEnd, progress ** 3, new Cesium.Cartesian3()), - point, - ); - // A Cartesian Bézier is a chord in world space and can pass through the - // ellipsoid when its orbit-tangent handle is long. Preserve the original - // climb's smooth altitude envelope while retaining the Bézier's horizontal - // curvature and tangent-aligned insertion. - const cartographic = ellipsoid.cartesianToCartographic(point); - const minimumHeight = altitudeEnvelope[index] ?? 0; - if (cartographic && cartographic.height < minimumHeight) { - cartographic.height = minimumHeight; - ascentPath[blendStart + index] = ellipsoid.cartographicToCartesian(cartographic); - } else { - ascentPath[blendStart + index] = point; - } - } - ascentPath[ascentPath.length - 1] = transferEnd; - return ascentPath; -} - -/** - * Build a globe-safe ascent ending at the nearest point on the selected orbit. - * The returned orbit is rotated to begin at that same insertion point so the - * animated marker cannot jump between ascent and orbital phases. - * @param {Cesium.Cartesian3} launchPosition Launch-site position. - * @param {Cesium.Cartesian3[]} trajectoryPositions Optional upstream stage fixes. - * @param {Cesium.Cartesian3[]} orbitPath Selected satellite or estimated orbit. - * @param {Cesium.Cartesian3|null} [insertionReference] Propagated or estimated insertion position. - * @returns {{ascentPath: Cesium.Cartesian3[], animatedOrbitPath: Cesium.Cartesian3[], insertionIndex: number}} - */ -export function buildMissionPaths( - launchPosition, - trajectoryPositions, - orbitPath, - insertionReference = null, -) { - const suppliedTrajectory = trajectoryPositions || []; - const controls = [launchPosition, ...suppliedTrajectory]; - const insertionIndex = nearestOrbitIndex( - orbitPath, - insertionReference || controls.at(-1), - ); - const transferEnd = orbitPath[insertionIndex]; - const ascentPath = suppliedTrajectory.length - ? surfaceSafePath([...controls, transferEnd]) - : reconstructedAscentPath(launchPosition, transferEnd); - blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex); - return { - ascentPath, - animatedOrbitPath: orbitPathFromInsertion(orbitPath, insertionIndex), - insertionIndex, - }; -} - -export function cameraHeadingForPath(path, progress, fallback = Math.PI) { - if (!path?.length) return fallback; - const current = samplePath(path, progress); - const currentCartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(current); - if (!currentCartographic) return fallback; - for (const step of [0.01, 0.025, 0.05, 0.1, 0.2]) { - const next = samplePath(path, Math.min(1, progress + step)); - const nextCartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(next); - if (!nextCartographic) continue; - const geodesic = new Cesium.EllipsoidGeodesic(currentCartographic, nextCartographic); - if (geodesic.surfaceDistance > 10 && Number.isFinite(geodesic.startHeading)) { - // HeadingPitchRange positions the camera opposite its heading vector. - // Passing the forward path heading therefore keeps the camera behind - // the vehicle, with the remaining ascent receding into the scene. - return Cesium.Math.zeroToTwoPi(geodesic.startHeading); - } - } - return fallback; -} - -/** - * Resolve the initial replay heading as a profile view of the path. - * @param {Cesium.Cartesian3[]} path Replay path. - * @returns {number} Heading perpendicular to the initial path direction. - */ -export function replayInitialCameraHeading(path) { - return Cesium.Math.zeroToTwoPi( - cameraHeadingForPath(path, 0, Math.PI) + Cesium.Math.PI_OVER_TWO, - ); -} - -/** - * Keep ascent framing behind and slightly to one side of the vehicle, then - * widen that rear-quarter angle as the orbit becomes visible. - * @param {number} pathHeading Forward path bearing in radians. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @returns {number} Cesium HeadingPitchRange heading. - */ -export function replayChaseCameraHeading(pathHeading, orbitBlend = 0) { - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); - return Cesium.Math.zeroToTwoPi( - pathHeading + Cesium.Math.lerp( - REPLAY_ASCENT_CAMERA_OFFSET_RAD, - REPLAY_ORBIT_CAMERA_OFFSET_RAD, - blend, - ), - ); -} - -/** - * Limit replay-camera yaw changes so a path heading wrap or insertion turn - * cannot swing the chase view through the front of the vehicle. - * @param {number} previous Previous Cesium heading in radians. - * @param {number} desired Desired Cesium heading in radians. - * @param {number} [maxStepRad] Maximum angular change for one rendered frame. - * @returns {number} - */ -export function smoothReplayCameraHeading( - previous, - desired, - maxStepRad = Cesium.Math.toRadians(2), -) { - if (!Number.isFinite(previous)) return Cesium.Math.zeroToTwoPi(desired); - if (!Number.isFinite(desired)) return Cesium.Math.zeroToTwoPi(previous); - const delta = Cesium.Math.negativePiToPi(desired - previous); - const step = Cesium.Math.clamp(delta, -Math.abs(maxStepRad), Math.abs(maxStepRad)); - return Cesium.Math.zeroToTwoPi(previous + step); -} - -/** - * Blend from a global nadir view into an oblique local 3D view as the user - * approaches a selected launch site. - * @param {number} rangeM Camera distance from the launch-site anchor. - * @returns {number} Cesium camera pitch in radians. - */ -export function missionZoomPitch(rangeM) { - const range = Math.max(0, Number(rangeM) || 0); - const blend = Cesium.Math.clamp( - (Math.log(Math.max(range, MISSION_CLOSE_VIEW_RANGE_M)) - Math.log(MISSION_CLOSE_VIEW_RANGE_M)) - / (Math.log(MISSION_GLOBE_VIEW_RANGE_M) - Math.log(MISSION_CLOSE_VIEW_RANGE_M)), - 0, - 1, - ); - return Cesium.Math.lerp(Cesium.Math.toRadians(-42), -Cesium.Math.PI_OVER_TWO, blend); -} - -/** - * Resolve the replay camera offset for either close ascent tracking or the - * orbital globe pullback. - * @param {{ascending: boolean, phaseProgress: number}} state Replay phase. - * @param {number} altitudeM Animated vehicle altitude above the ellipsoid. - * @returns {{range: number, pitch: number}} - */ -export function replayCameraView(state, altitudeM) { - const altitude = Math.max(0, Number(altitudeM) || 0); - const localRange = Cesium.Math.clamp( - REPLAY_INITIAL_RANGE_M + altitude * 0.7, - REPLAY_INITIAL_RANGE_M, - REPLAY_LOCAL_MAX_RANGE_M, - ); - const rawContextBlend = Cesium.Math.clamp( - (altitude - 20000) - / (REPLAY_CONTEXT_ALTITUDE_END_M - 20000), - 0, - 1, - ); - const contextBlend = rawContextBlend * rawContextBlend * (3 - 2 * rawContextBlend); - const contextRange = Cesium.Math.clamp( - 180000 + altitude * 3.8, - MISSION_CLOSE_VIEW_RANGE_M, - REPLAY_CONTEXT_MAX_RANGE_M, - ); - const ascentRange = Cesium.Math.lerp(localRange, contextRange, contextBlend); - const ascentPitch = Cesium.Math.lerp( - Cesium.Math.toRadians(-20), - Cesium.Math.toRadians(-34), - contextBlend, - ); - if (state?.ascending) { - return { range: ascentRange, pitch: ascentPitch }; - } - const rawBlend = Cesium.Math.clamp( - (Number(state?.phaseProgress) || 0) / REPLAY_ORBIT_PULLBACK_FRACTION, - 0, - 1, - ); - const blend = rawBlend * rawBlend * (3 - 2 * rawBlend); - return { - range: Cesium.Math.lerp(ascentRange, REPLAY_ORBIT_GLOBE_RANGE_M, blend), - // Keep an oblique tactical view of the complete orbit rather than ending - // in a nadir view. This also prevents the camera from appearing to - // return toward the launch site after insertion. - pitch: Cesium.Math.lerp(ascentPitch, Cesium.Math.toRadians(-45), blend), - }; -} - -/** - * Move the orbit-follow target from the vehicle toward its sub-satellite - * globe anchor, keeping Earth centered while the vehicle remains in frame. - * @param {Cesium.Cartesian3} position Animated orbital position. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @returns {Cesium.Cartesian3} Camera look-at target. - */ -export function replayOrbitGlobeAnchor(position, orbitBlend = 0) { - if (!position) return position; - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); - if (blend <= 0) return Cesium.Cartesian3.clone(position); - const cartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); - if (!cartographic) return Cesium.Cartesian3.clone(position); - const altitude = Math.max(0, cartographic.height || 0); - return Cesium.Cartesian3.fromRadians( - cartographic.longitude, - cartographic.latitude, - Cesium.Math.lerp(altitude, altitude * 0.1, blend), - ); -} - -/** - * Keep the orbital camera's look-at frame biased toward the moving vehicle. - * Blending completely to a whole-orbit bounding-sphere center can place the - * target at (or numerically close to) Earth's center. That frame is singular - * for a heading/pitch camera and lets compact-orbit vehicles leave the view as - * the camera rotates. Retaining a radial vehicle bias keeps the local frame - * stable while the wider camera range still contains Earth and the full orbit. - * @param {Cesium.Cartesian3} vehicleAnchor Globe-side anchor below the vehicle. - * @param {Cesium.Cartesian3|null} frameCenter Combined Earth/orbit frame center. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @returns {Cesium.Cartesian3} Stable camera look-at target. - */ -export function replayOrbitCameraTarget(vehicleAnchor, frameCenter, orbitBlend = 0) { - if (!vehicleAnchor) return vehicleAnchor; - if (!frameCenter) return Cesium.Cartesian3.clone(vehicleAnchor); - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1) - * REPLAY_ORBIT_FRAME_CENTER_BLEND; - return Cesium.Cartesian3.lerp( - vehicleAnchor, - frameCenter, - blend, - new Cesium.Cartesian3(), - ); -} - -/** - * Build a stable orbit-relative camera pose. The camera remains on one side - * of the orbital plane and uses the vehicle radial as its visual up axis. - * Consequently the forward orbit tangent always projects toward screen-left - * instead of changing direction when local compass headings wrap near a pole. - * @param {Cesium.Cartesian3} position Current vehicle position. - * @param {Cesium.Cartesian3} tangentPosition Nearby forward path position. - * @param {Cesium.Cartesian3} target Camera look-at target. - * @param {number} range Camera distance from the target. - * @param {number} pitch Camera elevation below the local horizon. - * @returns {{destination: Cesium.Cartesian3, direction: Cesium.Cartesian3, up: Cesium.Cartesian3}|null} - */ -export function replayOrbitCameraPose( - position, - tangentPosition, - target, - range, - pitch, -) { - if (!position || !tangentPosition || !target) return null; - const radial = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.clone(position), - new Cesium.Cartesian3(), - ); - const tangent = Cesium.Cartesian3.subtract( - tangentPosition, - position, - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.magnitudeSquared(tangent) < 1) return null; - Cesium.Cartesian3.normalize(tangent, tangent); - const orbitNormal = Cesium.Cartesian3.cross( - radial, - tangent, - new Cesium.Cartesian3(), - ); - if (Cesium.Cartesian3.magnitudeSquared(orbitNormal) < 1e-12) return null; - Cesium.Cartesian3.normalize(orbitNormal, orbitNormal); - - const distance = Math.max(1, Number(range) || 1); - const elevation = Cesium.Math.clamp( - Math.abs(Number(pitch) || 0), - Cesium.Math.toRadians(5), - Cesium.Math.toRadians(80), - ); - const planeOffset = Cesium.Cartesian3.multiplyByScalar( - orbitNormal, - Math.cos(elevation) * distance, - new Cesium.Cartesian3(), - ); - const radialOffset = Cesium.Cartesian3.multiplyByScalar( - radial, - Math.sin(elevation) * distance, - new Cesium.Cartesian3(), - ); - const destination = Cesium.Cartesian3.add( - target, - planeOffset, - new Cesium.Cartesian3(), - ); - Cesium.Cartesian3.add(destination, radialOffset, destination); - const direction = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(target, destination, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const radialAlongView = Cesium.Cartesian3.multiplyByScalar( - direction, - Cesium.Cartesian3.dot(radial, direction), - new Cesium.Cartesian3(), - ); - const up = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(radial, radialAlongView, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - return { destination, direction, up }; -} - -/** - * Build one conservative frame that contains both Earth and the complete - * selected orbit. High-apogee missions cannot be composed from the vehicle's - * instantaneous altitude alone because the opposite side of the orbit may - * extend much farther from the globe. - * @param {Cesium.Cartesian3[]} orbitPath Selected orbit samples. - * @returns {Cesium.BoundingSphere} Combined Earth/orbit frame. - */ -export function replayOrbitFrameSphere(orbitPath = []) { - const earth = new Cesium.BoundingSphere( - Cesium.Cartesian3.ZERO, - Cesium.Ellipsoid.WGS84.maximumRadius, - ); - if (!Array.isArray(orbitPath) || orbitPath.length < 2) return earth; - const orbit = Cesium.BoundingSphere.fromPoints(orbitPath); - return Cesium.BoundingSphere.union(earth, orbit, new Cesium.BoundingSphere()); -} - -/** - * Ensure high-altitude missions frame both the globe and selected vehicle. - * @param {number} baseRange Range from the normal replay camera transition. - * @param {number} altitudeM Vehicle altitude above the ellipsoid. - * @param {number} orbitBlend Normalized orbit-camera transition. - * @param {number} frameRadiusM Radius of the combined Earth/orbit frame. - * @returns {number} Camera range in metres. - */ -export function replayOrbitGlobeRange( - baseRange, - altitudeM, - orbitBlend = 0, - frameRadiusM = 0, -) { - const range = Math.max(0, Number(baseRange) || 0); - const altitude = Math.max(0, Number(altitudeM) || 0); - const frameRadius = Math.max(0, Number(frameRadiusM) || 0); - const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); - const globeAndVehicleRange = Math.max( - range, - altitude + Cesium.Ellipsoid.WGS84.maximumRadius * 2.4, - frameRadius * 3, - ); - return Cesium.Math.lerp(range, globeAndVehicleRange, blend); -} - -function stopMissionZoomAnchor() { - if (_missionZoomAnchorRemover) _missionZoomAnchorRemover(); - _missionZoomAnchorRemover = null; - _missionZoomAnchorId = null; - if (_viewer?.camera && !_replayCameraLaunchId) { - _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - } -} - -function startMissionZoomAnchor(launch, initialRange) { - if (!_viewer || !launch || _replayCameraLaunchId) return; - stopMissionZoomAnchor(); - const target = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - const range = Math.max(2000, Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M); - _missionZoomAnchorId = launch.id; - _viewer.camera.lookAt( - target, - new Cesium.HeadingPitchRange(0, missionZoomPitch(range), range), - ); -} - -function syncReplayButton() { - const button = _missionPanel?.querySelector('[data-mission-replay]'); - const transport = _missionPanel?.querySelector('[data-mission-replay-transport]'); - const speedControl = _missionPanel?.querySelector('.mission-replay-speed-control'); - if (!button) return; - const active = Boolean(_replayCameraLaunchId && _replayCameraLaunchId === _selectedLaunchId); - const replayAvailable = Boolean(_selectedLaunchId && _replayTracks.has(_selectedLaunchId)); - if (speedControl) speedControl.hidden = !replayAvailable; - button.hidden = active || !replayAvailable; - button.disabled = !replayAvailable; - button.textContent = 'REPLAY ASCENT'; - button.classList.remove('active'); - button.setAttribute('aria-pressed', String(active)); - button.title = 'Replay the estimated ascent with a following camera'; - if (transport) { - transport.hidden = !active; - transport.classList.toggle('is-paused', active && _replayPaused); - const toggleButton = transport.querySelector('[data-mission-replay-toggle]'); - if (toggleButton) { - toggleButton.disabled = !active; - toggleButton.textContent = _replayPaused ? '▶' : 'Ⅱ'; - toggleButton.setAttribute('aria-label', _replayPaused ? 'Resume replay' : 'Pause replay'); - toggleButton.title = _replayPaused ? 'Resume replay' : 'Pause replay'; - } - } -} - -function syncReplayCountdownButton(state) { - const transport = _missionPanel?.querySelector('[data-mission-replay-transport]'); - if (!transport || !_replayCameraLaunchId) return; - const phase = state.countdownActive - ? `T minus ${state.countdownSeconds}` - : state.preCountdownActive - ? 'Preparing launch site' - : state.elapsedSinceStart < 1 - ? 'Liftoff' - : state.ascending ? 'Ascent replay' : 'Orbit replay'; - transport.setAttribute('aria-label', `${phase}${_replayPaused ? ', paused' : ''}`); -} - -function syncReplaySpeedControl() { - const input = _missionPanel?.querySelector('[data-mission-replay-speed]'); - const output = _missionPanel?.querySelector('[data-mission-replay-speed-output]'); - if (input) { - input.value = String(_replaySpeed); - const progress = ((_replaySpeed - REPLAY_SPEED_MIN) / (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * 100; - input.style.setProperty('--replay-speed-progress', `${progress}%`); - } - if (output) output.textContent = `${_replaySpeed.toFixed(_replaySpeed % 1 ? 2 : 0)}×`; -} - -function setReplaySpeed(value) { - const nextSpeed = normalizeReplaySpeed(value); - const previousSpeed = _replaySpeed; - if (nextSpeed === previousSpeed) { - syncReplaySpeedControl(); - return; - } - const now = _replayPaused && Number.isFinite(_replayPausedAtMs) - ? _replayPausedAtMs - : Date.now(); - for (const [launchId, startedAt] of _animationStarts) { - if (!Number.isFinite(startedAt)) continue; - const elapsedMissionMs = (now - startedAt) * previousSpeed; - _animationStarts.set(launchId, now - elapsedMissionMs / nextSpeed); - } - _replaySpeed = nextSpeed; - syncReplaySpeedControl(); -} - -function replayClockNow(launchId) { - if ( - _replayPaused - && _replayCameraLaunchId === launchId - && Number.isFinite(_replayPausedAtMs) - ) { - return _replayPausedAtMs; - } - return Date.now(); -} - -function pauseMissionReplay() { - if (!_replayCameraLaunchId || _replayPaused) return false; - _replayPausedAtMs = Date.now(); - _replayPaused = true; - syncReplayButton(); - return true; -} - -function resumeMissionReplay() { - if (!_replayCameraLaunchId || !_replayPaused || !Number.isFinite(_replayPausedAtMs)) { - return false; - } - const resumedAt = Date.now(); - const startedAt = _animationStarts.get(_replayCameraLaunchId); - if (Number.isFinite(startedAt)) { - _animationStarts.set( - _replayCameraLaunchId, - replayStartAfterPause(startedAt, _replayPausedAtMs, resumedAt), - ); - } - _replayPaused = false; - _replayPausedAtMs = null; - syncReplayButton(); - return true; -} - -function stopMissionReplay() { - const stoppedLaunchId = _replayCameraLaunchId; - _replayCameraToken++; - if (_replayCameraRemover) _replayCameraRemover(); - _replayCameraRemover = null; - _viewer?.camera?.cancelFlight(); - if (_viewer?.camera) _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - _replayCameraLaunchId = null; - _replayPaused = false; - _replayPausedAtMs = null; - if (stoppedLaunchId) _animationStarts.set(stoppedLaunchId, Date.now()); - syncReplayButton(); - syncMissionOverlayEntries(); -} - -function startMissionReplay(launchId) { - const launch = _launches.find((item) => item.id === launchId); - const track = _replayTracks.get(launchId); - if (!_viewer || !launch || !track) return false; - if (_replayCameraLaunchId === launchId) { - stopMissionReplay(); - return false; - } - - stopMissionZoomAnchor(); - stopMissionReplay(); - _replayCameraLaunchId = launchId; - _replayPaused = false; - _replayPausedAtMs = null; - const token = ++_replayCameraToken; - const ascentDurationSec = track.ascentDurationSec; - // Start broadside to the ascent/orbit direction so the launch profile is - // visible. The chase heading then eases toward the path tangent after the - // camera is established. - const initialHeading = replayInitialCameraHeading(track.ascentPath); - track.lastCameraHeading = initialHeading; - syncReplayButton(); - syncMissionOverlayEntries(); - releaseAircraftTracking(_dataManager); - _viewer.trackedEntity = undefined; - _viewer.camera.cancelFlight(); - _animationStarts.set( - launchId, - Date.now() + (REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000, - ); - let cameraReady = false; - let lastCameraUpdateMs = null; - - // Move the chase camera before scene traversal. Mutating it in preRender - // makes Cesium discover a new view after 3D-tile refinement, which can cause - // a self-sustaining refinement loop and visible stutter during slow replay. - _replayCameraRemover = _viewer.scene.preUpdate.addEventListener(() => { - if (token !== _replayCameraToken || _replayCameraLaunchId !== launchId) return; - const state = track.beginReplayFrame(); - syncReplayCountdownButton(state); - if (!cameraReady) return; - const path = state.ascending - ? track.ascentPath - : track.animatedOrbitPath; - const position = samplePath(path, state.phaseProgress); - if (!position) return; - const pathHeading = cameraHeadingForPath( - path, - state.phaseProgress, - track.lastCameraHeading, - ); - const orbitBlend = !state.ascending - ? Cesium.Math.clamp( - (Number(state.phaseProgress) || 0) / REPLAY_ORBIT_PULLBACK_FRACTION, - 0, - 1, - ) - : 0; - const desiredHeading = replayChaseCameraHeading(pathHeading, orbitBlend); - if (state.ascending) track.orbitCameraWorldFrame = false; - const nowMs = performance.now(); - const frameDurationMs = Number.isFinite(lastCameraUpdateMs) - ? Cesium.Math.clamp(nowMs - lastCameraUpdateMs, 4, 50) - : 1000 / 60; - lastCameraUpdateMs = nowMs; - track.lastCameraHeading = smoothReplayCameraHeading( - track.lastCameraHeading, - desiredHeading, - Cesium.Math.toRadians(2) * frameDurationMs / (1000 / 60), - ); - const cartographic = Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); - const altitude = Math.max(0, cartographic?.height || 0); - const cameraView = replayCameraView(state, altitude); - const defaultOrbitTarget = !state.ascending - ? replayOrbitGlobeAnchor(position, orbitBlend) - : position; - const cameraTarget = !state.ascending - ? replayOrbitCameraTarget( - defaultOrbitTarget, - track.orbitFrameSphere?.center, - orbitBlend, - ) - : defaultOrbitTarget; - const cameraRange = !state.ascending - ? replayOrbitGlobeRange( - cameraView.range, - altitude, - orbitBlend, - track.orbitFrameSphere?.radius, - ) - : cameraView.range; - if (state.ascending) { - _viewer.camera.lookAt( - cameraTarget, - new Cesium.HeadingPitchRange( - track.lastCameraHeading, - cameraView.pitch, - cameraRange, - ), - ); - } else { - const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); - const tangentProgress = Math.min(1, state.phaseProgress + tangentStep); - const tangentPosition = samplePath( - path, - tangentProgress > state.phaseProgress - ? tangentProgress - : Math.max(0, state.phaseProgress - tangentStep), - ); - const orbitPose = replayOrbitCameraPose( - position, - tangentPosition, - cameraTarget, - cameraRange, - cameraView.pitch, - ); - if (orbitPose) { - if (!track.orbitCameraWorldFrame) { - _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); - track.orbitCameraWorldFrame = true; - } - _viewer.camera.setView({ - destination: orbitPose.destination, - orientation: { - direction: orbitPose.direction, - up: orbitPose.up, - }, - }); - } - } - if (state.elapsedSinceStart >= ascentDurationSec - + REPLAY_ORBIT_DURATION_SEC) { - stopMissionReplay(); - } - }); - - _viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(track.ascentPath[0], 0), - { - offset: new Cesium.HeadingPitchRange( - initialHeading, - Cesium.Math.toRadians(-20), - REPLAY_INITIAL_RANGE_M, - ), - duration: 1.4, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: () => { - cameraReady = true; - }, - cancel: () => { - if (token === _replayCameraToken && _replayCameraLaunchId === launchId) stopMissionReplay(); - }, - }, - ); - return true; -} - -function finiteCoordinate(value) { - const number = Number(value); - return Number.isFinite(number) ? number : null; -} - -function normalizePayloadFlights(launch) { - const flights = launch.rocket?.payloads || launch.payloads || launch.mission?.payloads || []; - if (!Array.isArray(flights)) return []; - return flights.map((flight, index) => { - const payload = flight.payload || flight; - return { - id: String(flight.id || payload.id || `payload-${index}`), - name: payload.name || flight.name || 'Unnamed payload', - type: payload.type?.name || flight.type?.name || null, - manufacturer: payload.manufacturer?.name || null, - operator: payload.operator?.name || null, - destination: flight.destination || payload.destination || null, - amount: Number.isFinite(Number(flight.amount)) ? Number(flight.amount) : 1, - massKg: (typeof payload.mass === 'number' || (typeof payload.mass === 'string' && payload.mass.trim() !== '')) - && Number.isFinite(Number(payload.mass)) && Number(payload.mass) >= 0 - ? Number(payload.mass) : null, - }; - }); -} - -function normalizeLanding(stage, fallbackName, category, index) { - const landing = stage?.landing; - const location = landing?.landing_location || {}; - const launcher = stage?.launcher || stage?.spacecraft || {}; - const serial = launcher.serial_number || stage?.serial_number || null; - const stageType = stage?.type?.name || stage?.type || category; - const attempted = landing?.attempt === true; - const success = landing?.success; - const recoveryType = landing?.type?.name || null; - const launcherStatus = launcher.status?.name || null; - const status = success === true ? 'RECOVERED' - : success === false ? 'LOST' - : attempted ? 'RECOVERY ATTEMPT' - : recoveryType ? recoveryType.toUpperCase() - : launcherStatus ? launcherStatus.toUpperCase() - : 'NO RECOVERY DATA'; - return { - id: String(stage?.id || landing?.id || `${category}-${index}`), - category, - name: [stageType, serial].filter(Boolean).join(' · ') || fallbackName, - serial, - reused: stage?.reused === true, - flightNumber: finiteCoordinate(stage?.launcher_flight_number), - status, - attempted, - success: success === true ? true : success === false ? false : null, - recoveryType, - destination: location.name || landing?.destination || landing?.type?.name || null, - description: landing?.description || null, - downrangeKm: finiteCoordinate(landing?.downrange_distance), - lat: finiteCoordinate(location.latitude ?? landing?.latitude), - lon: finiteCoordinate(location.longitude ?? landing?.longitude), - }; -} - -function normalizeRecoveryStages(launch, payloads) { - const rocket = launch.rocket || {}; - const launcherStages = Array.isArray(rocket.launcher_stage) ? rocket.launcher_stage : []; - const spacecraftStages = Array.isArray(rocket.spacecraft_stage) ? rocket.spacecraft_stage : []; - const stages = [ - ...launcherStages.map((stage, index) => normalizeLanding(stage, `Launcher stage ${index + 1}`, 'LAUNCHER', index)), - ...spacecraftStages.map((stage, index) => normalizeLanding(stage, `Spacecraft stage ${index + 1}`, 'SPACECRAFT', index)), - ]; - const payloadFlights = rocket.payloads || launch.payloads || []; - if (Array.isArray(payloadFlights)) { - payloadFlights.forEach((flight, index) => { - if (!flight?.landing) return; - stages.push(normalizeLanding( - { ...flight, type: payloads[index]?.type || 'Payload', serial_number: payloads[index]?.name }, - payloads[index]?.name || `Payload ${index + 1}`, - 'PAYLOAD', - index, - )); - }); - } - return stages; -} - -function landingEndpoint(stage, launch, insertionPosition) { - if (Number.isFinite(stage.lat) && Number.isFinite(stage.lon)) { - return { lat: stage.lat, lon: stage.lon, accuracy: 'CONFIRMED' }; - } - const recoveryIdentity = `${stage.recoveryType || ''} ${stage.destination || ''}`.toLowerCase(); - if (/return to launch site|rtls|launch site|landing zone/.test(recoveryIdentity)) { - return { lat: launch.lat, lon: launch.lon, accuracy: 'PAD / RTLS' }; - } - if (!(stage.downrangeKm > 0) || !insertionPosition) return null; - const ellipsoid = Cesium.Ellipsoid.WGS84; - const start = Cesium.Cartographic.fromDegrees(launch.lon, launch.lat); - const insertion = ellipsoid.cartesianToCartographic(insertionPosition); - if (!insertion) return null; - const geodesic = new Cesium.EllipsoidGeodesic(start, insertion, ellipsoid); - const bearing = geodesic.startHeading; - const angularDistance = (stage.downrangeKm * 1000) / ellipsoid.maximumRadius; - const lat1 = start.latitude; - const lon1 = start.longitude; - const lat = Math.asin( - Math.sin(lat1) * Math.cos(angularDistance) - + Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(bearing), - ); - const lon = lon1 + Math.atan2( - Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(lat1), - Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat), - ); - return { - lat: Cesium.Math.toDegrees(lat), - lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)), - accuracy: 'EST. DOWNRANGE', - }; -} - -function stageReentryRecoveryPath(ascentPath, endpoint, stageIndex, stageCount) { - if (!ascentPath?.length || !endpoint) return []; - const progress = Cesium.Math.clamp(0.28 + (stageIndex / Math.max(stageCount, 1)) * 0.34, 0.28, 0.68); - const separation = samplePath(ascentPath, progress); - const destination = Cesium.Cartesian3.fromDegrees(endpoint.lon, endpoint.lat, 12); - return surfaceSafeSegment(separation, destination); -} - -function atmosphericReentryIndex(path) { - if (!path?.length) return 0; - const ellipsoid = Cesium.Ellipsoid.WGS84; - for (let index = 1; index < path.length; index++) { - const previousHeight = ellipsoid.cartesianToCartographic(path[index - 1])?.height; - const height = ellipsoid.cartesianToCartographic(path[index])?.height; - if ( - Number.isFinite(previousHeight) - && Number.isFinite(height) - && previousHeight > STAGE_REENTRY_ALTITUDE_M - && height <= STAGE_REENTRY_ALTITUDE_M - ) { - return index; - } - } - return Math.min(Math.max(Math.round(path.length * 0.55), 0), path.length - 1); -} - -function missionTableRows(items, columns, emptyText) { - if (!items.length) return `${emptyText}`; - return items.map((item) => item).join(''); -} - -function setMissionPanelField(selector, value, title = '') { - const output = _missionPanel?.querySelector(selector); - if (!output) return; - const row = output.closest('[data-mission-field]'); - const available = value !== null && value !== undefined && String(value).trim() !== ''; - if (row) row.hidden = !available; - if (!available) { - output.textContent = ''; - output.removeAttribute('title'); - return; - } - output.textContent = value; - if (title) output.title = title; - else output.removeAttribute('title'); -} - -function renderMissionPanel() { - if (!_missionPanel) return; - const launch = _launches.find((item) => item.id === _selectedLaunchId); - const index = launch ? _launches.indexOf(launch) : -1; - _missionPanel.hidden = !launch; - if (!launch) return; - _missionPanel.querySelector('[data-mission-title]').textContent = shortMissionLabel(launch.name, 32).toUpperCase(); - setMissionPanelField('[data-mission-provider]', launch.provider); - setMissionPanelField('[data-mission-status]', launch.status); - setMissionPanelField( - '[data-mission-site]', - launch.launchSite && launch.launchSite !== 'Unknown launch site' ? launch.launchSite : null, - ); - setMissionPanelField('[data-mission-time]', launch.launchTime); - const pathPresentation = missionPathPresentation(launch, _replayTracks.has(launch.id)); - setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit); - _missionPanel.querySelector('[data-mission-ascent-source]').textContent = pathPresentation.ascent; - const payloadRows = launch.payloads.length - ? launch.payloads.slice(0, 5).map((payload) => { - const detail = [ - payload.manufacturer, - payload.operator && payload.operator !== payload.manufacturer ? payload.operator : null, - Number.isFinite(payload.massKg) ? `${payload.massKg.toLocaleString()} KG` : null, - ].filter(Boolean).join(' · '); - return `${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `${escapeMissionText(detail)}` : ''}${escapeMissionText(payload.type || 'UNSPECIFIED')}${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}`; - }) - : []; - if (launch.payloads.length > 5) { - payloadRows.push(`+${launch.payloads.length - 5} additional payload records`); - } - _missionPanel.querySelector('[data-mission-payloads]').innerHTML = missionTableRows( - payloadRows, - 3, - 'PAYLOAD DATA UNAVAILABLE', - ); - const stageRows = launch.recoveryStages.map((stage) => { - const endpoint = stage.endpoint; - const destination = stage.destination || (endpoint?.accuracy === 'PAD / RTLS' ? launch.launchSite : 'UNAVAILABLE'); - const position = endpoint - ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}` - : stage.downrangeKm > 0 ? `${stage.downrangeKm.toLocaleString()} KM DOWNRANGE` : 'POSITION UNAVAILABLE'; - const stageDetail = [ - Number.isFinite(stage.flightNumber) ? `FLIGHT ${stage.flightNumber}` : null, - stage.reused ? 'REUSED' : null, - stage.recoveryType, - ].filter(Boolean).join(' · '); - return `${escapeMissionText(stage.name)}${stageDetail ? `${escapeMissionText(stageDetail)}` : ''}${escapeMissionText(stage.status)}${escapeMissionText(destination)}${escapeMissionText(position)}`; - }); - _missionPanel.querySelector('[data-mission-stages]').innerHTML = missionTableRows( - stageRows, - 3, - 'NO STAGE RE-ENTRY / RECOVERY DATA', - ); - const stageSection = _missionPanel.querySelector('[data-mission-stages-section]'); - if (stageSection) stageSection.hidden = stageRows.length === 0; - updateMissionTelemetry(true); - _missionPanel.querySelector('[data-mission-index]').textContent = `${index + 1} / ${_launches.length}`; - _missionPanel.querySelector('[data-mission-prev]').disabled = index <= 0; - _missionPanel.querySelector('[data-mission-next]').disabled = index < 0 || index >= _launches.length - 1; - syncReplayButton(); - const panelScroller = _missionPanel.closest('.global-context-panel-inner'); - if (panelScroller) panelScroller.scrollTop = 0; -} - -function renderMissionRoster() { - if (!_missionRoster) return; - const list = _missionRoster.querySelector('[data-mission-roster-list]'); - const count = _missionRoster.querySelector('[data-mission-roster-count]'); - if (count) count.textContent = `${_launches.length} / 30D`; - if (!list) return; - const focusSnapshot = captureMissionRosterFocus(list); - _missionRosterPreviewOwnership?.reset(); - const entries = missionRosterEntries(_launches); - if (!entries.length) { - list.innerHTML = '
NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW
'; - restoreMissionRosterFocus( - list, - focusSnapshot, - _missionRoster.querySelector('[data-mission-roster-focus-continuation]'), - ); - return; - } - list.innerHTML = entries.map(({ launch, index }) => { - const color = missionMarkerColor(launch).toCssColorString(); - const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE'; - const provider = launch.provider || 'UNSPECIFIED OPERATOR'; - const label = shortMissionLabel(launch.name, 27).toUpperCase(); - return ``; - }).join(''); - list.querySelectorAll('[data-mission-roster-index]').forEach((button) => { - const index = Number(button.dataset.missionRosterIndex); - bindMissionRosterItemKeyboardPreview( - button, - index, - _missionRosterPreviewOwnership, - _missionRoster, - ); - }); - restoreMissionRosterFocus( - list, - focusSnapshot, - _missionRoster.querySelector('[data-mission-roster-focus-continuation]'), - ); -} - -function escapeMissionText(value) { - return String(value ?? '').replace(/[&<>"']/g, (character) => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - })[character]); -} - -function updateMissionTelemetry(force = false) { - if (!_missionPanel || !_selectedLaunchId || !_dataSource) return; - const now = performance.now(); - if (!force && now - _lastPanelTelemetryMs < 250) return; - _lastPanelTelemetryMs = now; - const satellite = _dataSource.entities.getById(`rocket-satellite:${_selectedLaunchId}`); - const position = satellite?.position?.getValue(Cesium.JulianDate.now(_declutterTime)); - const altitudeM = position ? Cesium.Cartographic.fromCartesian(position)?.height : null; - setMissionPanelField( - '[data-mission-distance]', - Number.isFinite(altitudeM) - ? `${Math.max(0, altitudeM / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} KM` - : null, - ); - const speedMps = _satelliteTelemetry.get(_selectedLaunchId)?.speedMps; - setMissionPanelField( - '[data-mission-speed]', - Number.isFinite(speedMps) - ? `${(speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KM/S` - : null, - Number.isFinite(speedMps) - ? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h` - : '', - ); -} - -function selectMissionAt(index) { - const launch = _launches[index]; - if (!launch) return; - setSelectedMission(launch.id, true); - focusMission(launch); -} - -function clearMissionRosterPreviewState() { - if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer); - _missionRosterHoverTimer = null; - const changed = _hoveredRosterLaunchId !== null; - _hoveredRosterLaunchId = null; - if (changed) syncMissionOverlayEntries(); -} - -function clearMissionRosterHover() { - if (_missionRosterPreviewOwnership) _missionRosterPreviewOwnership.reset(); - else clearMissionRosterPreviewState(); -} - -function previewMissionFromRoster(launch) { - if (!_viewer || !launch || _selectedLaunchId) return; - const range = missionHoverPreviewRange(_viewer.camera.positionCartographic?.height); - const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - _viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(position, 0), - { - offset: new Cesium.HeadingPitchRange(0, -Cesium.Math.PI_OVER_TWO, range), - duration: 0.8, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }, - ); -} - -function scheduleMissionRosterPreview(index) { - const launch = _launches[index]; - if (!launch || _selectedLaunchId) return; - if (_missionRosterHoverTimer) clearTimeout(_missionRosterHoverTimer); - _hoveredRosterLaunchId = launch.id; - syncMissionOverlayEntries(); - _missionRosterHoverTimer = setTimeout(() => { - _missionRosterHoverTimer = null; - if (_hoveredRosterLaunchId !== launch.id) return; - const currentLaunch = resolveMissionRosterPreviewLaunch(_launches, launch.id); - if (currentLaunch) previewMissionFromRoster(currentLaunch); - }, 140); -} - -function focusMission(launch) { - if (!_viewer || !launch) return; - stopMissionZoomAnchor(); - _viewer.selectedEntity = _dataSource.entities.getById(`rocket-launch:${launch.id}`); - const orbitEntity = _dataSource.entities.getById(`rocket-orbit:${launch.id}`); - const orbitPositions = orbitEntity?.polyline?.positions?.getValue(Cesium.JulianDate.now()); - const launchPosition = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - let range = 18000000; - if (orbitPositions?.length > 1) { - const canvas = _viewer.scene.canvas; - const fovy = _viewer.camera.frustum?.fovy || Cesium.Math.toRadians(60); - const aspect = Math.max(0.1, (canvas.clientWidth || canvas.width) / Math.max(1, canvas.clientHeight || canvas.height)); - const fovx = 2 * Math.atan(Math.tan(fovy * 0.5) * aspect); - const paddedHalfAngle = Math.min(fovy, fovx) * 0.5 * 0.62; - const orbitExtent = orbitPositions.reduce( - (largest, point) => Math.max(largest, Cesium.Cartesian3.distance(launchPosition, point)), - 0, - ); - range = Math.max( - orbitExtent / Math.max(Math.sin(paddedHalfAngle), 0.1), - 18000000, - ); - } - const targetNormal = Cesium.Cartesian3.normalize(launchPosition, new Cesium.Cartesian3()); - const destination = Cesium.Cartesian3.add( - launchPosition, - Cesium.Cartesian3.multiplyByScalar(targetNormal, range, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const direction = Cesium.Cartesian3.negate(targetNormal, new Cesium.Cartesian3()); - let right = Cesium.Cartesian3.cross(direction, Cesium.Cartesian3.UNIT_Z, new Cesium.Cartesian3()); - if (Cesium.Cartesian3.magnitudeSquared(right) < 1e-8) { - right = Cesium.Cartesian3.cross(direction, Cesium.Cartesian3.UNIT_Y, right); - } - Cesium.Cartesian3.normalize(right, right); - const up = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - _viewer.camera.flyTo({ - destination, - orientation: { direction, up }, - duration: 1.1, - complete: () => { - if (_selectedLaunchId === launch.id) startMissionZoomAnchor(launch, range); - }, - }); -} - -function focusLaunchSite(launch) { - if (!_viewer || !launch) return; - stopMissionReplay(); - stopMissionZoomAnchor(); - const launchPosition = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - _viewer.selectedEntity = _dataSource.entities.getById(`rocket-launch:${launch.id}`); - _viewer.camera.flyToBoundingSphere( - new Cesium.BoundingSphere(launchPosition, 0), - { - offset: new Cesium.HeadingPitchRange( - 0, - Cesium.Math.toRadians(-42), - MISSION_FOCUS_RANGE_M, - ), - duration: 1.4, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - complete: () => { - if (_selectedLaunchId === launch.id) { - startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M); - } - }, - }, - ); -} - -function createMissionPanel() { - if (_missionPanel || typeof document === 'undefined') return; - const host = document.getElementById('space-mission-panel-host') - || document.getElementById('right-context-rail'); - if (!host) return; - _missionRoster = document.getElementById('space-mission-roster'); - if (_missionRoster) { - _missionRosterPreviewOwnership = createMissionRosterPreviewOwnership({ - preview: scheduleMissionRosterPreview, - clear: clearMissionRosterPreviewState, - }); - _missionRoster.onclick = (event) => { - const button = event.target instanceof Element - ? event.target.closest('[data-mission-roster-index]') - : null; - if (!button) return; - selectMissionAt(Number(button.dataset.missionRosterIndex)); - }; - } - _missionPanel = document.createElement('aside'); - _missionPanel.id = 'space-mission-panel'; - _missionPanel.className = 'context-space-mission-detail'; - _missionPanel.setAttribute('aria-label', 'Selected Space Mission'); - _missionPanel.innerHTML = `
SELECTED SPACE MISSION
MISSIONSTATUS · LAUNCH SITE · LAUNCH TIME · ORBIT · ASCENT PATH · CURRENT DISTANCE FROM EARTH · SATELLITE SPEED ·

PAYLOAD

NAMETYPEDESTINATION

STAGE / RE-ENTRY / RECOVERY

STAGESTATUSFINAL POSITION
1×
—
`; - _missionPanel.querySelector('.mission-action-row').insertAdjacentHTML( - 'beforeend', - ``, - ); - host.appendChild(_missionPanel); - _missionPanel.querySelector('[data-mission-prev]').addEventListener('click', () => selectMissionAt(_launches.findIndex((item) => item.id === _selectedLaunchId) - 1)); - _missionPanel.querySelector('[data-mission-next]').addEventListener('click', () => selectMissionAt(_launches.findIndex((item) => item.id === _selectedLaunchId) + 1)); - _missionPanel.querySelector('[data-mission-close]').addEventListener('click', () => setSelectedMission(null)); - _missionPanel.querySelector('[data-mission-show-all]').addEventListener('click', () => setSelectedMission(null)); - _missionPanel.querySelector('[data-mission-focus]').addEventListener('click', () => { - const launch = _launches.find((item) => item.id === _selectedLaunchId); - if (launch) focusLaunchSite(launch); - }); - _missionPanel.querySelector('[data-mission-replay]').addEventListener('click', () => { - if (_selectedLaunchId) startMissionReplay(_selectedLaunchId); - }); - _missionPanel.querySelector('[data-mission-replay-toggle]').addEventListener('click', () => { - if (_replayPaused) resumeMissionReplay(); - else pauseMissionReplay(); - }); - _missionPanel.querySelector('[data-mission-replay-cancel]').addEventListener('click', stopMissionReplay); - _missionPanel.querySelector('[data-mission-replay-speed]').addEventListener('input', (event) => { - setReplaySpeed(event.currentTarget.value); - }); - syncReplaySpeedControl(); -} - -function updateReplayVehicleOverlay(occluder) { - if (!_viewer || !_replayVehicleOverlay || !_selectedLaunchId) { - hideReplayVehicleOverlay(); - return null; - } - const launch = _launches.find((item) => item.id === _selectedLaunchId); - const track = _replayTracks.get(_selectedLaunchId); - if (!launch || !track) { - hideReplayVehicleOverlay(); - return null; - } - const replayActive = _replayCameraLaunchId === launch.id; - const state = track.getReplayState(); - const mode = replayOverlayMode({ - replayActive, - ascending: state.ascending, - countdownActive: state.countdownActive, - preCountdownActive: state.preCountdownActive, - }); - if (!mode) { - track.lastOverlayWindowPosition = null; - track.lastOverlayMode = null; - hideReplayVehicleOverlay(); - return null; - } - const path = state.ascending - ? track.ascentPath - : track.animatedOrbitPath; - // Do not call scene.sampleHeight() from this post-render path. A selected - // mission can remain visible from globe range, and a periodic remote height - // probe forces Google Photorealistic 3D Tiles to reconsider its refinement - // set even though the camera is stationary. That presented as a one-second - // shrink/expand pulse across the globe. Replay follows its already-built, - // surface-safe Cartesian path and therefore needs no runtime pad sampling. - const position = samplePath(path, state.phaseProgress); - if (!position || !occluder.isPointVisible(position)) { - hideReplayVehicleOverlay(); - return null; - } - const windowPosition = Cesium.SceneTransforms.worldToWindowCoordinates( - _viewer.scene, - position, - ); - if (!windowPosition) { - hideReplayVehicleOverlay(); - return null; - } - const canvas = _viewer.scene.canvas; - if ( - windowPosition.x < -80 - || windowPosition.y < -100 - || windowPosition.x > canvas.clientWidth + 80 - || windowPosition.y > canvas.clientHeight + 100 - ) { - hideReplayVehicleOverlay(); - return null; - } - // The replay camera already targets this same frame-cached world position. - // Applying a second temporal filter here made the DOM vehicle trail the - // camera until the snap threshold was crossed, producing a repeating - // forward/back jump. Keep smoothing only for the non-tracked close-up pad - // marker, where the user can move the camera independently. - const renderedWindowPosition = !replayActive && track.lastOverlayMode === mode - ? smoothReplayWindowPosition(track.lastOverlayWindowPosition, windowPosition) - : windowPosition; - track.lastOverlayWindowPosition = renderedWindowPosition; - track.lastOverlayMode = mode; - _replayVehicleOverlay.hidden = false; - _replayVehicleOverlay.dataset.mode = mode; - _replayVehicleOverlay.classList.toggle( - 'is-thrusting', - replayActive && state.ascending && !state.countdownActive, - ); - _replayVehicleOverlay.classList.toggle('is-paused', replayActive && _replayPaused); - _replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`; - let vehicleRotation = 0; - if (replayActive && !state.countdownActive) { - const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); - const forwardProgress = Math.min(1, state.phaseProgress + tangentStep); - const backwardProgress = Math.max(0, state.phaseProgress - tangentStep); - const useForward = forwardProgress > state.phaseProgress; - const tangentPosition = samplePath( - path, - useForward ? forwardProgress : backwardProgress, - ); - const tangentWindowPosition = tangentPosition - ? Cesium.SceneTransforms.worldToWindowCoordinates(_viewer.scene, tangentPosition) - : null; - if (tangentWindowPosition) { - const screenDelta = Math.hypot( - tangentWindowPosition.x - windowPosition.x, - tangentWindowPosition.y - windowPosition.y, - ); - // When the path points almost directly into/out of the camera, its - // screen projection has no trustworthy direction. Keep the last valid - // path-facing pose instead of snapping the rocket to a camera-facing - // upright orientation. - if (screenDelta >= 2) { - vehicleRotation = useForward - ? replayVehicleScreenRotation(windowPosition, tangentWindowPosition) - : replayVehicleScreenRotation(tangentWindowPosition, windowPosition); - track.lastVehicleRotation = vehicleRotation; - } else if (Number.isFinite(track.lastVehicleRotation)) { - vehicleRotation = track.lastVehicleRotation; - } - } - } - _replayVehicleOverlay.style.setProperty( - '--mission-replay-rotation', - `${Cesium.Math.toDegrees(vehicleRotation)}deg`, - ); - - const mission = shortMissionLabel(launch.name, 22).toUpperCase(); - const siteName = compactLaunchSiteName(launch.launchSite); - const siteCallout = siteName - ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` - : 'LAUNCH SITE'; - let title = mission; - let detail = siteCallout; - if (mode === 'countdown') { - title = `T−${String(state.countdownSeconds).padStart(2, '0')} · ${mission}`; - detail = `LAUNCH STANDBY\n${siteCallout}`; - } else if (mode === 'ascent') { - title = state.elapsedSinceStart < 1 - ? `LIFTOFF · ${mission}` - : `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`; - detail = formatMissionEventTime(state.eventTime); - } else if (mode === 'recovery') { - title = `STAGE RE-ENTRY / RECOVERY · ${mission}`; - detail = formatMissionEventTime(state.eventTime); - } else if (mode === 'orbit') { - title = `ORBIT REPLAY · ${mission}`; - detail = formatMissionEventTime(state.eventTime); - } - if (_replayPaused) title = `PAUSED · ${title}`; - const nextText = `${title}\n${detail}`; - if (nextText !== _replayVehicleOverlayText) { - _replayVehicleOverlayText = nextText; - _replayVehicleOverlay.querySelector('[data-replay-overlay-title]').textContent = title; - _replayVehicleOverlay.querySelector('[data-replay-overlay-detail]').textContent = detail; - } - return mode; -} - -function setGraphicVisibility(graphic, visible, time) { - if (!graphic) return; - const next = Boolean(visible); - const current = graphic.show?.getValue?.(time); - if (current !== next) graphic.show = next; -} - -function updateMissionFrame() { - if (!_enabled || !_viewer || !_dataSource?.show) { - hideReplayVehicleOverlay(); - return; - } - updateMissionTelemetry(); - const time = Cesium.JulianDate.now(_declutterTime); - _declutterOccluder.cameraPosition = _viewer.scene.camera.positionWC; - updateReplayVehicleOverlay(_declutterOccluder); - refreshSelectedMissionOverlayText(); - for (const entity of _dataSource.entities.values) { - if (!entity.show) continue; - if (!entity.position) continue; - const id = entityLaunchId(entity); - if (!id) continue; - const position = entity.position.getValue(time); - let horizonVisible = Boolean(position && _declutterOccluder.isPointVisible(position)); - const launchAnchor = entity.id.startsWith('rocket-launch:'); - if (launchAnchor) { - horizonVisible = missionAnchorVisible( - _viewer.scene.camera.positionWC, - position, - id, - _selectedLaunchId, - ); - // Keep entity.show reserved for mission-selection isolation. Horizon - // state is applied to the individual graphics below so a rear-side - // anchor remains eligible for reevaluation as the camera moves. - } - if (entity.point) { - setGraphicVisibility(entity.point, horizonVisible, time); - } - if (launchAnchor && entity.billboard) { - setGraphicVisibility( - entity.billboard, - horizonVisible && id === _hoveredRosterLaunchId, - time, - ); - } - } -} - -/** - * Select a stable marker color from the mission operator and payload name. - * Labels stay cyan so the layer remains visually coherent. - * @param {object} launch Normalized launch record. - * @returns {Cesium.Color} - */ -export function missionMarkerColor(launch) { - const identity = `${launch.provider || ''} ${launch.name || ''} ${launch.missionName || ''}`.toLowerCase(); - if (/nasa|national aeronautics/.test(identity)) return Cesium.Color.fromCssColorString('#ff9f43'); - if (/starlink|spacex|space exploration/.test(identity)) return Cesium.Color.fromCssColorString('#4cc9f0'); - if (/rocket lab/.test(identity)) return Cesium.Color.fromCssColorString('#7bed9f'); - if (/isro|indian space/.test(identity)) return Cesium.Color.fromCssColorString('#ff66c4'); - if (/cnsa|china national|long march/.test(identity)) return Cesium.Color.fromCssColorString('#ffd166'); - if (/blue origin/.test(identity)) return Cesium.Color.fromCssColorString('#a78bfa'); - if (/ula|united launch alliance/.test(identity)) return Cesium.Color.fromCssColorString('#f97316'); - if (/arianespace|esa|european space/.test(identity)) return Cesium.Color.fromCssColorString('#60a5fa'); - if (launch.provider) return Cesium.Color.fromCssColorString('#c084fc'); - return Cesium.Color.fromCssColorString('#22e6e6'); -} - -/** - * Normalize a Launch Library 2 response into records suitable for the layer. - * Trajectory points are retained only when the upstream explicitly supplies - * them; orbital tracks must not be reconstructed from launch metadata. - * @param {object} payload Launch Library 2-compatible response. - * @param {Date} [now] Reference time used for the rolling window. - * @returns {Array} - */ -export function normalizeRocketLaunches(payload, now = new Date()) { - const launches = Array.isArray(payload) ? payload : payload?.results; - if (!Array.isArray(launches)) return []; - const cutoff = now.getTime() - WINDOW_DAYS * 86400000; - return launches.map((launch) => { - const launchTime = launch.net || launch.window_start || launch.pad?.location?.name; - const date = Date.parse(launchTime); - const pad = launch.pad || {}; - const location = pad.location || {}; - const coordinates = location.coordinates || ''; - const [coordinateLon, coordinateLat] = String(coordinates).split(',').map(Number); - const lat = Number.isFinite(Number(pad.latitude)) ? Number(pad.latitude) : coordinateLat; - const lon = Number.isFinite(Number(pad.longitude)) ? Number(pad.longitude) : coordinateLon; - const payloads = normalizePayloadFlights(launch); - return { - id: String(launch.id || launch.slug || launch.name || `launch-${date}`), - name: launch.name || 'Unnamed launch', - status: launch.status?.name || 'Unknown', - launchTime: Number.isFinite(date) ? new Date(date).toISOString() : null, - launchSite: pad.name || location.name || 'Unknown launch site', - lat: Number.isFinite(lat) ? lat : null, - lon: Number.isFinite(lon) ? lon : null, - provider: launch.launch_service_provider?.name || null, - mission: launch.mission?.description || null, - missionName: launch.mission?.name || null, - satelliteQuery: launch.mission?.name || launch.name || null, - payloads, - recoveryStages: normalizeRecoveryStages(launch, payloads), - trajectory: Array.isArray(launch.trajectory) ? launch.trajectory : [], - timeline: Array.isArray(launch.timeline) - ? launch.timeline.map((event) => ({ - name: event.type?.abbrev || event.type?.name || event.name || 'Mission event', - relativeTime: event.relative_time || event.relativeTime || null, - offsetSeconds: parseMissionDurationSeconds(event.relative_time || event.relativeTime), - })) - : [], - orbit: launch.mission?.orbit || launch.orbit || null, - source: 'Launch Library 2', - inWindow: Number.isFinite(date) && date >= cutoff && date <= now.getTime(), - }; - }).filter((launch) => launch.inWindow && launch.lat !== null && launch.lon !== null); -} - -function addLaunchEntity(launch, activeTleText = _activeTleText) { - const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); - const overlayRecord = { - launch, - anchorPosition: position, - elementEntryFactories: [], - liveEventTime: null, - }; - _missionOverlayRecords.set(launch.id, overlayRecord); - const orbitAllowed = launchStatusAllowsOrbit(launch.status); - const coreTrack = orbitAllowed && launch.satelliteQuery - ? getSatelliteOrbitTrack(launch.satelliteQuery, { launchTime: launch.launchTime }) - : null; - const satelliteTrack = coreTrack || (orbitAllowed && activeTleText && launch.satelliteQuery - ? findSatelliteOrbitTrackInTle( - activeTleText, - launch.satelliteQuery, - { launchTime: launch.launchTime }, - ) - : null); - const orbitPath = !orbitAllowed - ? null - : satelliteTrack?.orbitPath?.length > 1 - ? satelliteTrack.orbitPath - : approximateOrbitPath(launch); - launch.recoveryStages.forEach((stage) => { - stage.endpoint = landingEndpoint(stage, launch, orbitPath?.[0] || null); - }); - if (satelliteTrack) _orbitMatches++; - const entity = _dataSource.entities.add({ - id: `rocket-launch:${launch.id}`, - position, - point: { - pixelSize: 5, - color: missionMarkerColor(launch), - outlineColor: missionMarkerColor(launch), - outlineWidth: 0, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - billboard: { - image: missionHoverReticleImage(), - width: 24, - height: 24, - show: false, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { - launchId: launch.id, - launchName: launch.name, - launchTime: launch.launchTime, - launchSite: launch.launchSite, - provider: launch.provider, - mission: launch.mission, - status: launch.status, - source: launch.source, - orbit: JSON.stringify(launch.orbit || {}), - satelliteName: satelliteTrack?.name || '', - noradId: satelliteTrack?.noradId || '', - }, - }); - const points = launch.trajectory - .filter((point) => Number.isFinite(Number(point.latitude)) && Number.isFinite(Number(point.longitude))) - .map((point) => ({ - stage: String(point.stage || point.stage_name || point.phase || point.stageName || 'trajectory'), - position: Cesium.Cartesian3.fromDegrees(Number(point.longitude), Number(point.latitude), Number(point.altitude || 0)), - })); - if (points.length > 1) { - const segments = []; - points.forEach((point) => { - const previous = segments.at(-1); - if (!previous || previous.stage !== point.stage) segments.push({ stage: point.stage, positions: [] }); - segments.at(-1).positions.push(point.position); - }); - segments.forEach((segment, index) => { - if (segment.positions.length < 2) return; - _dataSource.entities.add({ - id: `rocket-trajectory:${launch.id}:${index}`, - polyline: { - positions: surfaceSafePath(segment.positions), - width: 2, - material: Cesium.Color.fromCssColorString(TRAJECTORY_STAGE_COLORS[index % TRAJECTORY_STAGE_COLORS.length]).withAlpha(0.8), - clampToGround: false, - arcType: Cesium.ArcType.NONE, - }, - properties: { launchId: launch.id, stage: segment.stage, source: launch.source }, - }); - }); - } - if (orbitPath?.length > 1) { - const orbitCurrent = satelliteTrack?.current || { longitude: launch.lon, latitude: launch.lat, altitude: 0 }; - const orbitPeriodSec = satelliteTrack?.periodSec || estimatedOrbitPeriodSeconds(orbitPath); - const launchEpochMs = Date.parse(launch.launchTime); - const disclosedInsertionOffsetSec = orbitInsertionOffsetSeconds(launch); - const insertionDurationSec = Number.isFinite(disclosedInsertionOffsetSec) - ? Math.max(0, disclosedInsertionOffsetSec) - : 600; - const insertionEpochMs = Number.isFinite(launchEpochMs) - ? launchEpochMs + insertionDurationSec * 1000 - : Number.NaN; - let insertionReference = points.at(-1)?.position || null; - if (!insertionReference && satelliteTrack?.positionAt && Number.isFinite(insertionEpochMs)) { - const propagatedInsertion = satelliteTrack.positionAt(new Date(insertionEpochMs)); - if (propagatedInsertion) { - insertionReference = Cesium.Cartesian3.fromDegrees( - propagatedInsertion.longitude, - propagatedInsertion.latitude, - propagatedInsertion.altitude, - ); - } - } - if (!insertionReference && !satelliteTrack) { - // A projected orbit has no authoritative historical phase. Start its - // plane over the launch site, advance only by the estimated powered - // ascent duration, and join the forward orbit tangent. Using the UTC - // epoch as phase sent some ascents toward the far side of Earth and - // forced a visible 180-degree corrective hook before insertion. - const insertionProgress = Cesium.Math.clamp( - insertionDurationSec / orbitPeriodSec, - 1 / 96, - 0.16, - ); - insertionReference = samplePath(orbitPath, insertionProgress); - } - const { ascentPath, animatedOrbitPath } = buildMissionPaths( - position, - points.map((point) => point.position), - orbitPath, - insertionReference, - ); - const ascentDurationSec = replayAscentDurationSeconds(launch, ascentPath); - // Cesium can evaluate CallbackProperty values multiple times while it - // traverses one frame. Camera tracking and the HTML replay overlay must - // use the exact same replay epoch as those callbacks; sampling Date.now() - // independently made the vehicle advance relative to the camera and then - // snap back on the next frame. - let replayStateForFrame = null; - const sampleReplayState = () => replayState( - launch, - _animationStarts.get(launch.id) || Date.now(), - ascentDurationSec, - REPLAY_ORBIT_DURATION_SEC, - orbitPeriodSec, - _replaySpeed, - replayClockNow(launch.id), - REPLAY_TILE_SETTLE_DELAY_SEC, - _replayCameraLaunchId !== launch.id, - ); - // preUpdate runs before Cesium evaluates entity CallbackProperties. Seed - // one state there and retain it through postRender so every consumer in - // that traversal sees the same timestamp. scene.frameState.frameNumber is - // advanced between some of those phases, so it is not a reliable cache key. - const beginReplayFrame = () => { - replayStateForFrame = sampleReplayState(); - return replayStateForFrame; - }; - const getReplayState = () => { - if (!replayStateForFrame) { - replayStateForFrame = sampleReplayState(); - } - return replayStateForFrame; - }; - launch.recoveryStages.forEach((stage, stageIndex) => { - stage.endpoint = landingEndpoint(stage, launch, ascentPath.at(-1)); - const path = stageReentryRecoveryPath(ascentPath, stage.endpoint, stageIndex, launch.recoveryStages.length); - if (path.length < 2) return; - const reentryIndex = atmosphericReentryIndex(path); - const reentryPath = path.slice(reentryIndex); - const color = Cesium.Color.fromCssColorString( - TRAJECTORY_STAGE_COLORS[(stageIndex + 1) % TRAJECTORY_STAGE_COLORS.length], - ); - _dataSource.entities.add({ - id: `rocket-reentry-recovery:${launch.id}:${stageIndex}`, - polyline: { - positions: path, - width: 2, - material: new Cesium.PolylineDashMaterialProperty({ - color: color.withAlpha(0.82), - dashLength: 12, - dashPattern: 0xAAAA, - }), - arcType: Cesium.ArcType.NONE, - }, - properties: { - launchId: launch.id, - stageId: stage.id, - phase: 'STAGE_REENTRY_RECOVERY', - accuracy: stage.endpoint.accuracy, - source: launch.source, - }, - }); - if (reentryPath.length > 1) { - _dataSource.entities.add({ - id: `rocket-reentry:${launch.id}:${stageIndex}`, - polyline: { - positions: reentryPath, - width: 2.5, - material: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.fromCssColorString('#ffd166').withAlpha(0.9), - dashLength: 8, - dashPattern: 0xF0F0, - }), - arcType: Cesium.ArcType.NONE, - }, - properties: { - launchId: launch.id, - stageId: stage.id, - phase: 'ATMOSPHERIC_REENTRY', - altitudeM: STAGE_REENTRY_ALTITUDE_M, - source: 'Estimated 100 km atmospheric interface', - }, - }); - const reentryPosition = path[reentryIndex]; - overlayRecord.elementEntryFactories.push(() => ( - createRocketMissionElementOverlayEntry({ - id: `reentry:${launch.id}:${stageIndex}`, - position: reentryPosition, - text: 'STAGE RE-ENTRY', - accent: '#ffd166', - priority: 700_000 - stageIndex, - gapPx: 8, - }) - )); - } - _dataSource.entities.add({ - id: `rocket-recovery-end:${launch.id}:${stageIndex}`, - position: path.at(-1), - point: { - pixelSize: 4, - color, - outlineWidth: 0, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { - launchId: launch.id, - stageId: stage.id, - phase: 'RECOVERY_ENDPOINT', - source: launch.source, - }, - }); - }); - _replayTracks.set(launch.id, { - ascentPath, - animatedOrbitPath, - orbitFrameSphere: replayOrbitFrameSphere(animatedOrbitPath), - ascentDurationSec, - beginReplayFrame, - getReplayState, - lastCameraHeading: Math.PI, - orbitCameraWorldFrame: false, - lastVehicleRotation: 0, - lastOverlayWindowPosition: null, - lastOverlayMode: null, - }); - let livePosition = Cesium.Cartesian3.fromDegrees( - orbitCurrent.longitude, - orbitCurrent.latitude, - orbitCurrent.altitude, - ); - const liveTime = new Date(); - const liveTelemetry = { speedMps: null }; - _satelliteTelemetry.set(launch.id, liveTelemetry); - let liveFrameNumber = -1; - const fallbackPeriodSec = estimatedOrbitPeriodSeconds(orbitPath); - const updateLiveState = () => { - const frameNumber = _viewer?.scene?.frameState?.frameNumber ?? -1; - if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; - liveFrameNumber = frameNumber; - const nowMs = Date.now(); - liveTime.setTime(nowMs); - if (satelliteTrack) { - const propagated = satelliteTrack.positionAt?.(liveTime) || satelliteTrack.current; - if (propagated) { - Cesium.Cartesian3.fromDegrees( - propagated.longitude, - propagated.latitude, - propagated.altitude, - undefined, - livePosition, - ); - liveTelemetry.speedMps = Number.isFinite(propagated.speedMps) - ? propagated.speedMps - : null; - } - } else { - // In-place write keeps one Cartesian3 for the mission's lifetime — - // the estimated branch previously reallocated per propagation. - livePosition = samplePath( - orbitPath, - orbitProgressAtTime(nowMs, fallbackPeriodSec), - livePosition, - ) || livePosition; - } - }; - _dataSource.entities.add({ - id: `rocket-satellite:${launch.id}`, - position: new Cesium.CallbackProperty(() => { - updateLiveState(); - return livePosition; - }, false), - point: { - pixelSize: 6, - color: Cesium.Color.fromCssColorString(satelliteTrack ? '#7bed9f' : '#ffd166'), - outlineWidth: 0, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - properties: { - launchId: launch.id, - noradId: satelliteTrack?.noradId || '', - phase: satelliteTrack ? 'CURRENT_SATELLITE_POSITION' : 'ESTIMATED_ORBIT_POSITION', - source: satelliteTrack ? 'Satellites layer' : 'Approximate mission orbit', - }, - }); - overlayRecord.liveEventTime = () => liveTime; - overlayRecord.elementEntryFactories.push(() => { - const name = satelliteTrack - ? shortMissionLabel(satelliteTrack.name, 22).toUpperCase() - : 'EST. ORBIT POSITION'; - return createRocketMissionElementOverlayEntry({ - id: `payload-position:${launch.id}`, - position: () => livePosition, - text: `${name}\n${formatMissionEventTime(liveTime)}`, - accent: satelliteTrack ? '#7bed9f' : '#ffd166', - priority: 900_000, - gapPx: 12, - }); - }); - // Live satellite rings use a primitive collection so the core Satellite - // GMST transform can rotate their baked ECEF geometry without rebuilding - // it. Estimated launch-site-relative rings remain ordinary entities. - const primitiveOrbitAdded = addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack); - if (!primitiveOrbitAdded) { - _dataSource.entities.add({ - id: `rocket-orbit:${launch.id}`, - polyline: { - positions: orbitPath, - // The 3 px canvas gives the periodic dot room to read as round; the - // shader masks the ten intervening dashes down to a thin center stroke. - width: 3, - material: new MissionOrbitPatternMaterialProperty( - Cesium.Color.fromCssColorString('#c084fc').withAlpha(0.95), - ), - arcType: Cesium.ArcType.NONE, - }, - properties: { - launchId: launch.id, - satelliteName: '', - noradId: '', - source: 'Approximate mission orbit', - }, - }); - } - const orbitLabelBakePosition = ascentPath.at(-1); - let orbitLabelPosition = orbitLabelBakePosition; - const primitivePath = _missionOrbitPrimitives.get(launch.id); - if (primitivePath) { - primitivePath.labelBakePosition = orbitLabelBakePosition; - primitivePath.labelPosition = Cesium.Matrix4.multiplyByPoint( - primitivePath.primitive.modelMatrix, - orbitLabelBakePosition, - new Cesium.Cartesian3(), - ); - orbitLabelPosition = () => primitivePath.labelPosition; - } - overlayRecord.elementEntryFactories.push(() => ( - createRocketMissionElementOverlayEntry({ - id: `orbit:${launch.id}`, - position: orbitLabelPosition, - text: satelliteTrack ? 'ORBIT' : 'PROJECTED ORBIT', - accent: satelliteTrack ? '#22e6e6' : '#c084fc', - priority: 800_000, - gapPx: 8, - }) - )); - const replayPosition = new Cesium.CallbackProperty(() => { - const state = getReplayState(); - return state.ascending - ? samplePath(ascentPath, state.phaseProgress) - : samplePath(animatedOrbitPath, state.phaseProgress); - }, false); - _dataSource.entities.add({ - id: `rocket-vehicle:${launch.id}`, - position: replayPosition, - properties: { launchId: launch.id, noradId: satelliteTrack?.noradId || '', phase: 'ASCENT_THEN_ORBIT', source: satelliteTrack ? 'Satellite trajectory animation' : 'Approximate trajectory animation' }, - }); - _dataSource.entities.add({ - id: `rocket-transfer:${launch.id}`, - polyline: { - positions: ascentPath, - width: 1.5, - // The animated vehicle uses Cartesian interpolation between these - // exact samples. Prevent Cesium from replacing each segment with a - // geodesic arc, which would visually separate the dot from the line. - arcType: Cesium.ArcType.NONE, - material: new Cesium.PolylineDashMaterialProperty({ - color: Cesium.Color.fromCssColorString('#7bed9f').withAlpha(0.8), - dashLength: 24, - dashPattern: 0xF0F0, - }), - }, - properties: { - launchId: launch.id, - phase: 'APPROXIMATE_TRANSFER', - source: satelliteTrack - ? 'Launch site to propagated insertion' - : 'Launch site to forward projected insertion', - }, - }); - } -} - -function clearPostTleRetry() { - if (_retryTimer) clearTimeout(_retryTimer); - _retryTimer = null; -} - -function schedulePostTleRetry(token) { - if (_retryTimer || token !== _lifecycleToken || !shouldRetryAfterActiveTle({ - enabled: _enabled, - retryCount: _postTleRetryCount, - activeTleText: _activeTleText, - renderedTleText: _renderedTleText, - })) return; - _postTleRetryCount++; - _retryTimer = setTimeout(() => { - _retryTimer = null; - if (_enabled && token === _lifecycleToken) requestMissionUpdate(); - }, POST_TLE_RETRY_DELAY_MS); -} - -function ensureActiveTleLookup(token) { - if (_activeTleText) return Promise.resolve(_activeTleText); - if (_activeTlePromise && _activeTlePromiseToken === token) return _activeTlePromise; - const request = fetch('/api/celestrak/active') - .then((activeResponse) => { - if (!activeResponse.ok) throw new Error(`HTTP ${activeResponse.status}`); - return activeResponse.text(); - }) - .then((text) => { - if (!_enabled || token !== _lifecycleToken) return null; - _activeTleText = text; - _focusAfterActiveLookup = Boolean(_selectedLaunchId); - schedulePostTleRetry(token); - return text; - }) - .catch((error) => { - if (_enabled && token === _lifecycleToken) { - console.warn('[Data:RocketLaunches] Active satellite lookup unavailable:', error.message); - } - return null; - }) - .finally(() => { - if (_activeTlePromise === request) { - _activeTlePromise = null; - _activeTlePromiseToken = 0; - } - }); - _activeTlePromise = request; - _activeTlePromiseToken = token; - return request; -} - -async function captureSatelliteDependency() { - if (!_dataManager || _satelliteStateBeforeMission) return; - _satelliteStateBeforeMission = { - // Effective visibility: a user enable still mid-activation is intent ON — - // capturing settled false would restore the user's enable away on exit. - enabled: _dataManager.isEffectivelyEnabled?.('satellites') - ?? _dataManager.isEnabled('satellites'), - params: satelliteParamsAfterSpaceMissions( - _dataManager.getLayerParams('satellites'), - ), - }; - _dataManager.setLayerParams( - 'satellites', - satelliteParamsForSpaceMissions(_satelliteStateBeforeMission.params), - ); - const token = _lifecycleToken; - const activation = Promise.resolve(_dataManager.setEnabled('satellites', true)); - _satelliteActivationPromise = activation; - try { - const activated = await activation; - // Space Missions without its satellite dependency is a broken replay - // surface; fail the mission enable so the manager's fail-closed path and - // the Context rollback see an honest failure instead of a silent success. - if ( - token === _lifecycleToken - && _enabled - && (activated === false || !_dataManager.isEnabled('satellites')) - ) { - throw new Error('Space Missions requires the satellites layer, which failed to start'); - } - } finally { - if (token === _lifecycleToken && _satelliteActivationPromise === activation) { - _satelliteActivationPromise = null; - } - } -} - -async function restoreSatelliteDependency() { - const snapshot = _satelliteStateBeforeMission; - if (!snapshot || !_dataManager) return; - _satelliteStateBeforeMission = null; - _satelliteActivationPromise = null; - _dataManager.setLayerParams( - 'satellites', - satelliteParamsAfterSpaceMissions(snapshot.params), - ); - const restored = await _dataManager.setEnabled('satellites', snapshot.enabled); - if ( - restored === false - || _dataManager.isEnabled('satellites') !== snapshot.enabled - ) { - throw new Error('Space Missions could not restore the satellites layer'); - } -} - -async function performMissionUpdate(token) { - try { - ensureActiveTleLookup(token); - const response = await fetch(API_URL); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const launches = normalizeRocketLaunches(await response.json()); - if (!_enabled || token !== _lifecycleToken || !_dataSource) return; - const activeTleText = _activeTleText; - if (_replayCameraLaunchId) stopMissionReplay(); - _launches = launches; - removeMissionOrbitPrimitives(); - _dataSource.entities.removeAll(); - _missionOverlayRecords.clear(); - _satelliteTelemetry.clear(); - _replayTracks.clear(); - _orbitMatches = 0; - launches.forEach((launch) => addLaunchEntity(launch, activeTleText)); - _renderedTleText = activeTleText; - if (_renderedTleText === _activeTleText) clearPostTleRetry(); - _count = launches.length; - renderMissionRoster(); - if (!_selectedLaunchId || !launches.some((launch) => launch.id === _selectedLaunchId)) { - setSelectedMission(null, false); - } else { - setSelectedMission(_selectedLaunchId, true); - if (_focusAfterActiveLookup) { - focusMission(launches.find((launch) => launch.id === _selectedLaunchId)); - } - } - _focusAfterActiveLookup = false; - renderMissionPanel(); - _lastUpdate = Date.now(); - _lastError = null; - } catch (error) { - if (_enabled && token === _lifecycleToken) { - _lastError = error.message; - console.warn('[Data:RocketLaunches] Fetch error:', error); - } - } -} - -function requestMissionUpdate() { - if (!_enabled) return Promise.resolve(); - const token = _lifecycleToken; - _updateDirty = true; - if (_updatePromise && _updatePromiseToken === token) return _updatePromise; - let request; - request = (async () => { - while (_enabled && token === _lifecycleToken && _updateDirty) { - _updateDirty = false; - await performMissionUpdate(token); - } - })().finally(() => { - if (_updatePromise === request) { - _updatePromise = null; - _updatePromiseToken = 0; - } - }); - _updatePromise = request; - _updatePromiseToken = token; - return request; -} - -const rocketLaunchesLayer = { - id: 'rocket-launches', - name: 'Space Missions (30d)', - icon: '🚀', - source: 'Launch Library 2', - updateInterval: 300000, - - init(viewer) { - _viewer = viewer; - _enabled = false; - createMissionPanel(); - createReplayVehicleOverlay(); - _dataSource = new Cesium.CustomDataSource('rocket-launches'); - _dataSource.show = false; - viewer.dataSources.add(_dataSource); - _count = 0; - _lastUpdate = null; - _lastError = null; - _orbitMatches = 0; - clearMissionOverlaySources(); - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((movement) => { - if (!_enabled || !_dataSource?.show) return; - const entity = viewer.scene.drillPick(movement.position, 12) - .map((picked) => picked?.id) - .find((candidate) => entityLaunchId(candidate)); - const launchId = entityLaunchId(entity); - if (!launchId) return; - setSelectedMission(launchId); - focusMission(_launches.find((launch) => launch.id === launchId)); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - _moveHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _moveHandler.setInputAction((movement) => { - if (!_enabled || !_dataSource?.show) return; - const missionEntity = viewer.scene.drillPick(movement.endPosition, 12) - .map((picked) => picked?.id) - .find((candidate) => entityLaunchId(candidate)); - viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - // Apply horizon visibility before Cesium traverses and picks the scene. - // A postRender write is one frame late and can remain visibly stale when - // request-on-demand rendering stops after a globe camera move. - _declutterHandler = viewer.scene.preRender.addEventListener(updateMissionFrame); - initLaunchPadZonePrimitive(); - }, - - async enable() { - _enabled = true; - holdContinuousRender('rocket-launches'); // per-frame animator (perf wave 2) - _lifecycleToken++; - _postTleRetryCount = 0; - try { - await this._enableBody(); - } catch (error) { - // Enable is a transaction: a failed dependency must not leave mission - // UI/datasource visible or the satellite snapshot retained (a retained - // snapshot makes the next enable skip dependency capture entirely). - try { - await this.disable(); - } catch (cleanupError) { - console.warn('[Missions] enable rollback failed:', cleanupError); - } - throw error; - } - }, - async _enableBody() { - _updateDirty = false; - if (_dataSource) _dataSource.show = true; - syncMissionOrbitPrimitiveVisibility(); - focusFullGlobe(_viewer); - document.getElementById('cockpit-context')?.setAttribute('hidden', ''); - if (_selectedLaunchId) setSelectedMission(_selectedLaunchId, _explicitSelection); - else setSelectedMission(null, false); - await captureSatelliteDependency(); - }, - async disable() { - _enabled = false; - releaseContinuousRender('rocket-launches'); - _lifecycleToken++; - _updateDirty = false; - clearPostTleRetry(); - clearMissionRosterHover(); - stopMissionReplay(); - stopMissionZoomAnchor(); - if (_dataSource) _dataSource.show = false; - syncMissionOrbitPrimitiveVisibility(); - if (_viewer?.scene?.canvas) _viewer.scene.canvas.style.cursor = ''; - hideLaunchPadZone(); - hideReplayVehicleOverlay(); - _selectedLaunchId = null; - clearMissionOverlaySources(); - await restoreSatelliteDependency(); - renderMissionPanel(); - }, - - update() { - return requestMissionUpdate(); - }, - - /** Release only Space Mission camera ownership, preserving layer and selection state. */ - releaseCameraOwnership() { - clearMissionRosterHover(); - stopMissionReplay(); - stopMissionZoomAnchor(); - }, - - async destroy(viewer) { - releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) - _enabled = false; - _lifecycleToken++; - _updateDirty = false; - await restoreSatelliteDependency(); - clearMissionRosterHover(); - stopMissionReplay(); - stopMissionZoomAnchor(); - clearPostTleRetry(); - if (_clickHandler) _clickHandler.destroy(); - _clickHandler = null; - if (_moveHandler) _moveHandler.destroy(); - _moveHandler = null; - if (_declutterHandler) _declutterHandler(); - _declutterHandler = null; - destroyLaunchPadZonePrimitive(); - removeMissionOrbitPrimitives(); - clearMissionOverlaySources(); - destroyReplayVehicleOverlay(); - _launches = []; - _missionOverlayRecords.clear(); - _animationStarts.clear(); - _satelliteTelemetry.clear(); - _replayTracks.clear(); - _missionPanel?.remove(); - _missionPanel = null; - if (_missionRoster) { - _missionRoster.onclick = null; - } - _missionRosterPreviewOwnership = null; - _missionRoster = null; - _viewer = null; - if (_dataSource) viewer.dataSources.remove(_dataSource, true); - _dataSource = null; - _count = 0; - _orbitMatches = 0; - _lastUpdate = null; - _activeTleText = null; - _activeTlePromise = null; - _activeTlePromiseToken = 0; - _renderedTleText = null; - _postTleRetryCount = 0; - _satelliteStateBeforeMission = null; - _satelliteActivationPromise = null; - _dataManager = null; - }, - - getStats() { return { count: _count, orbitMatches: _orbitMatches, lastUpdate: _lastUpdate, error: _lastError }; }, - attachDataManager(dataManager) { _dataManager = dataManager; }, -}; - -/** Test seam for real layer lifecycle coverage with a recording host. */ -export function _setRocketMissionOverlayHostForTest(host = null) { - _missionOverlayHost = host ? { ...DEFAULT_OVERLAY_HOST, ...host } : DEFAULT_OVERLAY_HOST; -} - -/** Test seam that exercises the real selection/deselection path. */ -export function _setSelectedRocketMissionForTest(launchId = null) { - setSelectedMission(launchId, Boolean(launchId)); -} - -export default rocketLaunchesLayer; +export const satelliteParamsForSpaceMissions = + layer.satelliteParamsForSpaceMissions; +export const satelliteParamsAfterSpaceMissions = + layer.satelliteParamsAfterSpaceMissions; +export const launchStatusAllowsOrbit = layer.launchStatusAllowsOrbit; +export const missionPathPresentation = layer.missionPathPresentation; +export const shouldRetryAfterActiveTle = layer.shouldRetryAfterActiveTle; +export const releaseAircraftTracking = layer.releaseAircraftTracking; +export const launchPadZoneVisible = layer.launchPadZoneVisible; +export const missionAnchorHorizonVisible = layer.missionAnchorHorizonVisible; +export const missionAnchorVisible = layer.missionAnchorVisible; +export const compactLaunchSiteName = layer.compactLaunchSiteName; +export const createRocketMissionMarkerOverlayEntry = + layer.createRocketMissionMarkerOverlayEntry; +export const createRocketMissionElementOverlayEntry = + layer.createRocketMissionElementOverlayEntry; +export const selectRocketMissionMarkerOverlayCohort = + layer.selectRocketMissionMarkerOverlayCohort; +export const replayOverlayMode = layer.replayOverlayMode; +export const replayVehicleScreenRotation = layer.replayVehicleScreenRotation; +export const smoothReplayWindowPosition = layer.smoothReplayWindowPosition; +export const missionDataCompleteness = layer.missionDataCompleteness; +export const missionRosterEntries = layer.missionRosterEntries; +export const missionHoverPreviewRange = layer.missionHoverPreviewRange; +export const createMissionRosterPreviewOwnership = + layer.createMissionRosterPreviewOwnership; +export const bindMissionRosterItemKeyboardPreview = + layer.bindMissionRosterItemKeyboardPreview; +export const captureMissionRosterFocus = layer.captureMissionRosterFocus; +export const restoreMissionRosterFocus = layer.restoreMissionRosterFocus; +export const resolveMissionRosterPreviewLaunch = + layer.resolveMissionRosterPreviewLaunch; +export const formatMissionEventTime = layer.formatMissionEventTime; +export const parseMissionDurationSeconds = layer.parseMissionDurationSeconds; +export const replayAscentDurationSeconds = layer.replayAscentDurationSeconds; +export const normalizeReplaySpeed = layer.normalizeReplaySpeed; +export const replayStartAfterPause = layer.replayStartAfterPause; +export const replayState = layer.replayState; +export const approximateOrbitPath = layer.approximateOrbitPath; +export const samplePath = layer.samplePath; +export const orbitProgressAtTime = layer.orbitProgressAtTime; +export const reconstructedAscentPath = layer.reconstructedAscentPath; +export const buildMissionPaths = layer.buildMissionPaths; +export const cameraHeadingForPath = layer.cameraHeadingForPath; +export const replayInitialCameraHeading = layer.replayInitialCameraHeading; +export const replayChaseCameraHeading = layer.replayChaseCameraHeading; +export const smoothReplayCameraHeading = layer.smoothReplayCameraHeading; +export const missionZoomPitch = layer.missionZoomPitch; +export const replayCameraView = layer.replayCameraView; +export const replayOrbitGlobeAnchor = layer.replayOrbitGlobeAnchor; +export const replayOrbitCameraTarget = layer.replayOrbitCameraTarget; +export const replayOrbitCameraPose = layer.replayOrbitCameraPose; +export const replayOrbitFrameSphere = layer.replayOrbitFrameSphere; +export const replayOrbitGlobeRange = layer.replayOrbitGlobeRange; +export const missionMarkerColor = layer.missionMarkerColor; +export const normalizeRocketLaunches = layer.normalizeRocketLaunches; +export const _setRocketMissionOverlayHostForTest = + layer._setRocketMissionOverlayHostForTest; +export const _setSelectedRocketMissionForTest = + layer._setSelectedRocketMissionForTest; +export { + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + LAUNCH_PAD_ZONE_RADIUS_M, +} from '../layers/launches/index.js'; +export default layer; diff --git a/src/data/satellites.js b/src/data/satellites.js index 0fe8b6d02..ecad3df45 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -1,2397 +1,54 @@ -import * as Cesium from 'cesium'; -import { twoline2satrec, propagate, gstime, eciToGeodetic, degreesLong, degreesLat } from 'satellite.js'; -import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js'; -import { findNextIssPass } from './issPass.js'; import { - advanceSpriteFocus, - clearFocusTarget, - focusAlphaNeedsWrite, - focusNowMs, - focusPassIsNeeded, - getFocusTarget, - nearFarScalarValueAtDistance, - publishFocusTargetFromCachedPosition, -} from './focusDeemphasis.js'; -import { refreshTrackedReadout } from './trackedReadout.js'; -import { - satelliteClassColor, - satelliteClassLabel, - satelliteClassLegend, - tallySatelliteClasses, -} from './satelliteClass.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -import { - clearTrackedSubjectContext, - getContextStore, - refreshTrackedSubjectContext, - selectTrackedSubjectContext, -} from './contextStore.js'; -import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; -import { isExplicitLayerStateOrigin } from './layerState.js'; + createSatellitesLayer, + createSatelliteSource, +} from '../layers/satellites/index.js'; +import * as picking from './pickRegistry.js'; +import * as focus from './focusDeemphasis.js'; +import * as readout from './trackedReadout.js'; +import * as overlays from '../overlays/worldOverlay.js'; +import * as context from './contextStore.js'; +import * as render from '../renderGovernor.js'; +import * as layerState from './layerState.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. - */ - -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, +const layer = createSatellitesLayer({ + source: createSatelliteSource(), + services: { picking, focus, readout, overlays, context, render, layerState }, }); -const DEFAULT_OVERLAY_HOST = Object.freeze({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, -}); -let _overlayHost = DEFAULT_OVERLAY_HOST; -const ORBIT_PATH_STEPS = 180; // points per orbital path -const POSITION_UPDATE_MS = 1000; // re-propagate every 1s (SGP4 is smooth at this rate) -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). - */ -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). -const DENSE_GROUP_PATH = 'starlink'; -const DENSE_REFRESH_FRAMES = 300; // full dense pass spread over ~300 frames (~5s @ 60fps) -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. - */ -const TRACK_VIEW_FROM_LEO = new Cesium.Cartesian3(-450000, -450000, 350000); -const HIGH_ORBIT_ALTITUDE_M = 2000000; -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. - */ -const POINT_OUTLINE = Cesium.Color.WHITE.withAlpha(0.3); -const _classColor = (group) => Cesium.Color.fromCssColorString(satelliteClassColor(group)); - -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, - }, -}; - -/** - * 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; -} - -// Satellite catalog: { noradId → { name, satrec, group } } -let _catalog = new Map(); -let _pointCollection = null; -let _points = new Map(); // noradId → point primitive -/** Stable lightweight records reused by the detection overlay between updates. */ -let _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). -let _orbitPaths = new Map(); -let _count = 0; -let _lastUpdate = null; -/** @type {string|null} Surfaced feed error (e.g. CelesTrak outage) for the layer chip. */ -let _lastError = null; -const _activeUpdateControllers = new Set(); -let _denseLoadController = null; - -function _abortActiveUpdates() { - for (const controller of _activeUpdateControllers) controller.abort(); - _activeUpdateControllers.clear(); - _denseLoadController?.abort(); - _denseLoadController = null; -} -let _viewer = null; -let _preRenderListener = null; -let _lastPropagation = 0; -let _lastRingRotation = 0; -let _lastFocusUpdate = 0; -/** Points whose animated emphasis remains outside the 1.0 deadband. */ -let _activeFocusCount = 0; -const _scratchFocusScreen = new Cesium.Cartesian2(); -let _enabled = false; - -// Click-to-track state -let _trackedNorad = null; -let _pendingTrackingRestore = null; -let _trackingIntentGeneration = 0; -let _trackingRefreshEpoch = 0; -let _lastTrackingRefreshOutcome = { - epoch: 0, - status: 'unavailable', - failedGroups: [], -}; -let _trackedEntity = null; -let _clickHandler = null; -/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */ -let _trackedEntityChangedRemove = null; - -// Runtime params (DataLayerManager.setLayerParams path) -let _params = { catalog: 'core', showPoints: true, showOrbits: true }; // 'core' | 'dense' -let _denseIds = []; // norad ids of dense extras, round-robin order -let _denseCursor = 0; // next dense id to re-propagate -let _denseLoadToken = 0; // invalidates in-flight dense loads on mode flip/reload -let _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. - */ -let _denseStatus = 'idle'; -/** @type {string|null} Why the last dense load failed, for the chip tooltip. */ -let _denseError = null; -/** Bumped on every bulk catalog mutation; keys the row-legend tally cache. */ -let _catalogRevision = 0; -let _classTallyCache = { revision: -1, counts: null }; -/** @type {(() => void)|null} Manager callback: "this layer's row controls changed". */ -let _rowControlsListener = null; - -/** Tell the manager to re-render this layer's row (chip state / legend counts). */ -function _notifyRowControls() { - try { - _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} Class key → count. - */ -function _classTally() { - if (_classTallyCache.counts && _classTallyCache.revision === _catalogRevision) { - return _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 _catalog) { - entries.push({ group: sat.group, isIss: noradId === ISS_NORAD }); - } - const counts = tallySatelliteClasses(entries); - _classTallyCache = { revision: _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. - */ -export 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. - */ -export function satelliteCatalogModeChanged(currentCatalog, requestedCatalog) { - return (requestedCatalog === 'core' || requestedCatalog === 'dense') - && requestedCatalog !== currentCatalog; -} - -/** Build the persistent ISS ambient label from the cached point position. */ -export 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 _points.get(ISS_NORAD)?.position || null; -} - -/** - * 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. - */ -const DOCKED_COMPANION_RADIUS_M = 2000; -/** The scan is O(points); the tracked label is rebuilt every frame, so throttle. */ -const DOCKED_SCAN_INTERVAL_MS = 1000; -/** @type {Set} NORAD ids co-located with the tracked satellite. */ -let _dockedCompanions = new Set(); -let _lastDockedScanMs = Number.NEGATIVE_INFINITY; - -/** - * Rebuild the docked-companion set for the tracked satellite. - * @returns {boolean} true when membership changed (callers resync presentation). - */ -function _refreshDockedCompanions(nowMs) { - const trackedPosition = _trackedNorad === null ? null : _trackedDisplayCached(); - if (!trackedPosition) { - if (_dockedCompanions.size === 0) return false; - _dockedCompanions = new Set(); - return true; - } - if (nowMs - _lastDockedScanMs < DOCKED_SCAN_INTERVAL_MS) return false; - _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 _points) { - if (noradId === _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 (!_dockedCompanions.has(noradId)) changed = true; - } - if (found !== _dockedCompanions.size) changed = true; - if (changed) _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 _dockedCompanions) { - names.push(_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 = _enabled && _params.showOrbits && _trackedNorad !== ISS_NORAD - && !_dockedCompanions.has(ISS_NORAD) - && _catalog.has(ISS_NORAD) && _issDisplayCached(); - if (!visible) { - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - return; - } - _overlayHost.setEntries( - ISS_OVERLAY_SOURCE_ID, - [createIssOverlayEntry(_issDisplayCached)], - ISS_OVERLAY_SOURCE_OPTIONS, - ); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, true); -} - -// 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. -let _trackedFrameNumber = -1; -let _trackedFrameGeo = null; // { longitude, latitude, altitude } or null -const _trackedFrameCartesian = new Cesium.Cartesian3(); -/** Optional deterministic clock used only by the production-frame test seam. */ -let _trackedFrameNowForTest = null; - -// Scratch variables -const _scratchCartesian = new Cesium.Cartesian3(); -const _scratchRingRotation = new Cesium.Matrix3(); - -/** - * 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. - */ -export function orbitFrameModelMatrix( - gmstAtBake, - nowDate, - result = new Cesium.Matrix4(), -) { - const deltaGmst = gstime(nowDate) - gmstAtBake; - const rotation = Cesium.Matrix3.fromRotationZ(-deltaGmst, _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; -} - -/** - * 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 (_orbitPaths.has(noradId)) return; // already showing - - const sat = _catalog.get(noradId); - if (!sat || !_viewer) return; - - const bakeDate = new Date(); - const basePositions = 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 - }); - _viewer.scene.primitives.add(primitive); - - _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 (_orbitPaths.size === 0) return; - - for (const path of _orbitPaths.values()) { - if (!path.primitive) continue; - orbitFrameModelMatrix(path.gmstAtBake, nowDate, path.primitive.modelMatrix); - } -} - -/** - * Remove orbital path for a satellite. - */ -function _hideOrbitPath(noradId) { - const path = _orbitPaths.get(noradId); - if (path) { - if (_viewer) _viewer.scene.primitives.remove(path.primitive); // remove() destroys - _orbitPaths.delete(noradId); - } -} - -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 = _pendingTrackingRestore; - if (!pending || pending.generation !== _trackingIntentGeneration || !_enabled) return false; - if (!_viewer || !_catalog.has(pending.id) || !_points.has(pending.id)) return false; - _pendingTrackingRestore = null; - _trackSatellite(pending.id, { origin: pending.origin }); - return _trackedNorad === pending.id; -} - -function _cancelPendingTrackingRestore() { - _trackingIntentGeneration += 1; - _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. - _dockedCompanions = new Set(); - _lastDockedScanMs = Number.NEGATIVE_INFINITY; - if (!_trackedNorad) { - clearFocusTarget('satellites'); - _syncIssOverlay(); - return; - } - const clearedNorad = _trackedNorad; - clearFocusTarget('satellites', _trackedNorad); - - const lastPos = _points.get(_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 = _pointStyleFor(_trackedNorad, _catalog.get(_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) - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - - // Remove tracked entity and orbit path (unless ISS — keep its path) - if (_trackedNorad !== ISS_NORAD) { - _hideOrbitPath(_trackedNorad); - } - if (_viewer && !skipViewerUntrack) _viewer.trackedEntity = undefined; - if (_trackedEntity) { - _viewer.entities.remove(_trackedEntity); - _trackedEntity = null; - } - _trackedNorad = null; - _syncIssOverlay(); - clearTrackedSubjectContext('satellites'); - _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 (_trackedNorad === null) return null; - const sat = _catalog.get(_trackedNorad); - if (!sat) return null; - - const frameNumber = _viewer?.scene?.frameState?.frameNumber ?? -1; - if (frameNumber === -1 || frameNumber !== _trackedFrameNumber || _trackedFrameGeo === null) { - const pos = propagatePosition( - sat.satrec, - _trackedFrameNowForTest ? new Date(_trackedFrameNowForTest()) : new Date(), - ); - if (!pos) return _trackedFrameGeo; // propagation hiccup — keep last good sample - _trackedFrameGeo = pos; - Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude, undefined, _trackedFrameCartesian); - _trackedFrameNumber = frameNumber; - // Throttled inside; membership changes are rare, so resync the ISS ambient - // gate only when the cluster actually changed. - const clusterChanged = _refreshDockedCompanions( - _trackedFrameNowForTest ? _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) _syncIssOverlay(); - const name = sat.name?.trim() || `SAT-${_trackedNorad}`; - const altitudeText = `${Math.round(pos.altitude / 1000)} km · NORAD ${_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: _trackedNorad, - scene: _viewer?.scene, - camera: _viewer?.camera, - displayPosition: _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 _trackedFrameGeo; -} - -/** Cached ECEF display point only; never propagates a fresh SGP4 sample. */ -function _trackedDisplayCached() { - return _trackedFrameGeo ? _trackedFrameCartesian : null; -} - -/** Update the explicit model only when the rounded altitude line changes. */ -/** Epoch of the last shared-context refresh for the tracked satellite. */ -let _contextRefreshedAtMs = 0; -/** - * 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. - */ -const CONTEXT_REFRESH_INTERVAL_MS = 1000; - -/** - * 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 = _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} Resolves once the applicable catalog has settled. - */ -async function _reconcileTrackedSubjectContext() { - const subjectAtStart = _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 = _params.catalog === 'dense' ? _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 (_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); - } - _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 (_catalog.size === 0) return; - if (_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 && _denseStatus !== 'ready') return; - clearTrackedSubjectContext('satellites'); - _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 (_trackedNorad === null) return; - const now = Date.now(); - if (now - _contextRefreshedAtMs < CONTEXT_REFRESH_INTERVAL_MS) return; - _contextRefreshedAtMs = now; - refreshTrackedSubjectContext(_contextSubjectMetadata(_trackedNorad)); -} - -function _updateTrackedSatelliteLabelModel(fallbackAltitudeM = null) { - if (!_trackedEntity || _trackedNorad === null) return; - const sat = _catalog.get(_trackedNorad); - const title = sat?.name?.trim() || `SAT-${_trackedNorad}`; - const altitudeM = _trackedFrameGeo?.altitude ?? fallbackAltitudeM; - const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${_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: _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 = _dockedCompanionNames(); - if (companions.length > 0) { - const extra = companions.length - 1; - details.push(`DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`); - } - const current = _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; - _trackedEntity.gevLabelModel = { title, details, accent: '#ffd84d' }; - refreshTrackedReadout(_trackedEntity); -} - -function _trackSatellite(noradId, { origin = 'programmatic' } = {}) { - _clearTracking(false, { origin }); - - const point = _points.get(noradId); - const sat = _catalog.get(noradId); - if (!point || !sat) return; - - _trackedNorad = noradId; - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _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 - _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 ? _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 = 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() - ); - - _trackedEntity = _viewer.entities.add({ - position: positionProperty, - viewFrom, - point: { - pixelSize: 14, - color: Cesium.Color.YELLOW, - outlineColor: Cesium.Color.WHITE, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - _trackedEntity.gevSelectionOrigin = origin; - _trackedEntity.gevTrackedId = `satellites:${noradId}`; - _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)); - _contextRefreshedAtMs = Date.now(); - - _viewer.trackedEntity = _trackedEntity; - console.log(`[Data:Satellites] Tracking ${name} (NORAD ${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 _catalog) { - if (sat.group === 'dense') continue; - const pos = propagatePosition(sat.satrec, now); - if (!pos) continue; - - const cartesian = Cesium.Cartesian3.fromDegrees(pos.longitude, pos.latitude, pos.altitude); - const point = _points.get(noradId); - if (point) { - point.position = cartesian; - updated++; - } - } - - return updated; -} - -/** - * 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 (_denseIds.length === 0) return; - const perFrame = Math.max(1, Math.ceil(_denseIds.length / DENSE_REFRESH_FRAMES)); - const now = new Date(); - for (let i = 0; i < perFrame; i++) { - if (_denseCursor >= _denseIds.length) _denseCursor = 0; - const noradId = _denseIds[_denseCursor++]; - if (noradId === _trackedNorad) continue; // per-frame tracked path owns it - const sat = _catalog.get(noradId); - const point = _points.get(noradId); - if (!sat || !point) continue; - const pos = 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 (!_viewer || !_pointCollection) return { status: 'source-unavailable', reason: 'layer-unavailable' }; - _denseLoadController?.abort(); - const resourceController = new AbortController(); - _denseLoadController = resourceController; - const loadSignal = signal - ? AbortSignal.any([signal, resourceController.signal]) - : resourceController.signal; - const token = ++_denseLoadToken; - _denseStatus = 'loading'; - _denseError = null; - _notifyRowControls(); - try { - loadSignal.throwIfAborted(); - const res = await fetch(`/api/celestrak/${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 = await res.text(); - loadSignal.throwIfAborted(); - if (token !== _denseLoadToken || _params.catalog !== 'dense') { - return { status: 'superseded', reason: 'dense-load-superseded' }; - } - - const entries = 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 !== _denseLoadToken || _params.catalog !== 'dense' || !_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 (_catalog.has(noradId)) continue; // core catalog keeps priority - const pos = propagatePosition(satrec, now); - if (!pos) continue; - - _catalog.set(noradId, { name: entry.name, satrec, group: 'dense' }); - const point = _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, - }); - _points.set(noradId, point); - _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' }; - } - - _count = _points.size; - _catalogRevision++; - _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. - _notifyRowControls(); - _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 (_denseLoadController === resourceController) _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 !== _denseLoadToken) return; - _params.catalog = 'core'; - _removeDenseCatalog(); - _denseStatus = 'failed'; - _denseError = reason; - _notifyRowControls(); -} - -/** Remove all dense extras (catalog entries, points, tracking if needed). */ -function _removeDenseCatalog() { - _denseLoadController?.abort(); - _denseLoadController = null; - _denseLoadToken++; // cancel any in-flight dense load - if (_trackedNorad !== null && _catalog.get(_trackedNorad)?.group === 'dense') { - _clearTracking(); - } - for (const noradId of _denseIds) { - const point = _points.get(noradId); - if (point && _pointCollection) _pointCollection.remove(point); - _points.delete(noradId); - _catalog.delete(noradId); - } - _denseIds = []; - _denseCursor = 0; - _count = _points.size; - _catalogRevision++; -} - -/** - * 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 (!_enabled) return; - const now = focusNowMs(Date.now()); - - const interval = _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 (_params.showPoints && now - _lastPropagation >= interval) { - _propagateAll(); - _lastPropagation = now; - } - - if (_params.showPoints) _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 (_trackedNorad !== null) { - const pos = _getTrackedFramePosition(); - const point = _points.get(_trackedNorad); - if (pos && point) { - point.position = _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 (_params.showOrbits && now - _lastRingRotation >= RING_ROTATION_MS) { - _updateOrbitPathRotations(new Date(now)); - _lastRingRotation = now; - } -} - -/** - * 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 - */ -export 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 = [], -}) { - _viewer = viewer; - _catalog = new Map([[noradId, { name, satrec, group: 'stations' }]]); - _points = new Map([[noradId, point]]); - for (const neighbour of neighbours) { - _catalog.set(neighbour.noradId, { - name: neighbour.name, - satrec: neighbour.satrec || satrec, - group: neighbour.group || 'stations', - }); - _points.set(neighbour.noradId, neighbour.point); - } - _dockedCompanions = new Set(); - _lastDockedScanMs = Number.NEGATIVE_INFINITY; - _trackedNorad = noradId; - _trackedEntity = entity; - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _trackedFrameNowForTest = now; - _params = { catalog: 'core', showPoints: false, showOrbits: false }; - _enabled = true; -} - -/** Seed catalog authority and optional dense settlement for share-Follow tests. */ -export function _setSatelliteTrackingRefreshOutcomeForTest({ - status = 'accepted', - failedGroups = [], - catalog = 'core', - densePromise = null, -} = {}) { - const epoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { epoch, status, failedGroups: [...failedGroups] }; - _params.catalog = catalog; - _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. */ -export function _trackedFrameCartesianForTest() { - return _trackedFrameCartesian; -} - -/** Invoke the same callback registered on `scene.preRender` in production. */ -export function _runSatellitePreRenderForTest() { - _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] - */ -export function _setDenseCatalogStateForTest({ catalog = 'core', showPoints = true } = {}) { - _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. - _overlayHost = { setEntries() {}, setVisible() {}, clearSource() {} }; - _pointCollection = { - show: true, - add: (opts) => ({ ...opts }), - remove() {}, - removeAll() {}, - }; - _catalog = new Map(); - _points = new Map(); - _detectionObjects = new Map(); - _orbitPaths = new Map(); - _denseIds = []; - _denseCursor = 0; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _catalogRevision++; - _trackedNorad = null; - _cancelPendingTrackingRestore(); - _params = { catalog, showPoints, showOrbits: false }; - _enabled = true; -} - -/** Tear the dense seam back down so ordering cannot leak into other tests. */ -export function _clearDenseCatalogStateForTest() { - _rowControlsListener = null; - _overlayHost = DEFAULT_OVERLAY_HOST; - _orbitPaths = new Map(); - _viewer = null; - _pointCollection = null; - _catalog = new Map(); - _points = new Map(); - _denseIds = []; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _params = { catalog: 'core', showPoints: true, showOrbits: true }; - _cancelPendingTrackingRestore(); - _enabled = false; -} - -/** Catalog group tag recorded for a satellite, for ingestion-path assertions. */ -export function _catalogGroupForTest(noradId) { - return _catalog.get(Number(noradId))?.group; -} - -/** Seed ISS/tracking state while retaining the production track and host paths. */ -export function _setSatelliteLabelLifecycleStateForTest({ - viewer, - satrec, - point, - overlayHost, - preservePending = false, -}) { - _viewer = viewer; - _catalog = new Map([[ISS_NORAD, { name: 'ISS (ZARYA)', satrec, group: 'stations' }]]); - _points = new Map([[ISS_NORAD, point]]); - _orbitPaths = new Map([[ - ISS_NORAD, - { primitive: { show: true, modelMatrix: new Cesium.Matrix4() }, gmstAtBake: 0 }, - ]]); - _trackedNorad = null; - _trackedEntity = null; - if (!preservePending) _cancelPendingTrackingRestore(); - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _enabled = true; - _params = { catalog: 'core', showPoints: true, showOrbits: true }; - _overlayHost = overlayHost || DEFAULT_OVERLAY_HOST; - _syncIssOverlay(); -} - -/** Exercise production ISS tracking from the cached catalog and point. */ -export function _trackIssForTest() { - _trackSatellite(ISS_NORAD); - return _trackedEntity; -} - -/** Return the deferred restore target held by the production tracker. */ -export function _pendingSatelliteTrackingRestoreForTest() { - return _pendingTrackingRestore?.id ?? null; -} - -/** Exercise the production deferred-restore retry after a simulated catalog refresh. */ -export function _applyPendingSatelliteTrackingRestoreForTest() { - return _applyPendingTrackingRestore(); -} - -/** Remove a cached catalog row so tests can model a target arriving later. */ -export function _removeSatelliteTrackingCandidateForTest(noradId) { - const id = Number(noradId); - _catalog.delete(id); - _points.delete(id); -} - -/** Exercise production untrack and restore the default host seam. */ -export function _clearSatelliteLabelLifecycleForTest() { - _clearTracking(); - _enabled = false; - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - _overlayHost = DEFAULT_OVERLAY_HOST; -} - -/** Focus alpha for satellite points, inside the existing shared preRender tick. */ -function _updatePointFocus(nowMs) { - const target = getFocusTarget(); - if (!_params.showPoints || !focusPassIsNeeded(target, _activeFocusCount)) return; - if (nowMs - _lastFocusUpdate < 80) return; - _lastFocusUpdate = nowMs; - const scene = _viewer.scene; - const camera = _viewer.camera; - const result = applySatellitePointFocusDeemphasis({ - points: _points, - trackedId: _trackedNorad, - target, - previousActiveCount: _activeFocusCount, - nowMs, - screenPositionFor: (position) => ( - Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, _scratchFocusScreen) - ), - cameraDistanceFor: (position) => Cesium.Cartesian3.distance(camera.positionWC, position), - baseColorFor: (noradId) => _pointStyleFor(noradId, _catalog.get(noradId)?.group).color, - }); - _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}} - */ -export 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 }; -} - - -const satellitesLayer = { - id: 'satellites', - name: 'Satellites', - icon: '🛰️', - source: 'CelesTrak', - updateInterval: 0, // We use preRender for real-time updates, not interval polling - refreshInterval: 5 * 60 * 1000, // Catalog data refresh; propagation remains preRender-owned. - - async init(viewer) { - _abortActiveUpdates(); - clearFocusTarget('satellites'); - _viewer = viewer; - _catalog = new Map(); - _points = new Map(); - _detectionObjects = new Map(); - _orbitPaths = new Map(); - _count = 0; - _lastUpdate = null; - _trackedNorad = null; - _cancelPendingTrackingRestore(); - _trackedEntity = null; - _trackedFrameNumber = -1; - _trackedFrameGeo = null; - _trackedFrameNowForTest = null; - _lastFocusUpdate = 0; - _activeFocusCount = 0; - _enabled = false; - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _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). - _denseIds = []; - _denseCursor = 0; - _denseLoadPromise = null; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _catalogRevision++; - - // Point primitives for satellite dots - _pointCollection = new Cesium.PointPrimitiveCollection(); - viewer.scene.primitives.add(_pointCollection); - - _installClickHandler(viewer); - - // Pre-render listener for real-time position updates - // (fleet propagation + tracked per-frame dot + orbit ring GMST rotation) - _preRenderListener = viewer.scene.preRender.addEventListener(_preRenderTick); - - console.log('[Data:Satellites] Initialized'); - }, - - enable(viewer) { - _enabled = true; - holdContinuousRender('satellites'); // per-frame animator (perf wave 2) - if (_pointCollection) _pointCollection.show = satelliteVisualsVisible(_enabled, _params.showPoints); - // Orbit ring primitives + persistent ISS host label — show them - for (const path of _orbitPaths.values()) path.primitive.show = satelliteVisualsVisible(_enabled, _params.showOrbits); - _syncIssOverlay(); - // Re-attach input handlers and preRender propagation - _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) && _points.has(norad); - }); - if (!_preRenderListener && viewer) { - _preRenderListener = viewer.scene.preRender.addEventListener(_preRenderTick); - } - _applyPendingTrackingRestore(); - }, - - disable(viewer) { - _abortActiveUpdates(); - _cancelPendingTrackingRestore(); - _enabled = false; - releaseContinuousRender('satellites'); - if (_pointCollection) _pointCollection.show = false; - for (const path of _orbitPaths.values()) path.primitive.show = false; - _clearTracking(); - _syncIssOverlay(); - // Remove click handler + keydown listener + preRender propagation while disabled - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('satellites'); - if (_preRenderListener) { - _preRenderListener(); - _preRenderListener = null; - } - }, - - async update(viewer, { signal = null } = {}) { - const trackingRefreshEpoch = ++_trackingRefreshEpoch; - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: 'source-unavailable', - failedGroups: [], - }; - const resourceController = new AbortController(); - _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 fetch(`/api/celestrak/${groupDef.path}`, { signal: updateSignal }); - if (!res.ok) return { ...groupDef, entries: [], ok: false }; - const entries = parseTLE(await 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)) { - _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. - _lastError = failed.length - ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` - : null; - - // Clear existing - _pointCollection.removeAll(); - _points.clear(); - for (const path of _orbitPaths.values()) viewer.scene.primitives.remove(path.primitive); - _orbitPaths.clear(); - _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. - _detectionObjects.clear(); - _denseIds = []; - _denseCursor = 0; - _denseLoadController?.abort(); - _denseLoadController = null; - _denseLoadToken++; // cancel any in-flight dense load against the old catalog - _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 - _catalog.set(noradId, { - name: entry.name, - satrec, - group: entry.group, - }); - - // Propagate initial position - const pos = 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 = _pointStyleFor(noradId, entry.group); - const point = _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, - }); - - _points.set(noradId, point); - } - - // Show ISS orbital path by default - if (_catalog.has(ISS_NORAD)) { - _showOrbitPath(ISS_NORAD, POINT_STYLES.iss.color); - const issPath = _orbitPaths.get(ISS_NORAD); - if (issPath) issPath.primitive.show = _params.showOrbits; - - _syncIssOverlay(); - } - - _count = _points.size; - _catalogRevision++; - _lastUpdate = Date.now(); - _lastPropagation = Date.now(); - _lastTrackingRefreshOutcome = { - epoch: trackingRefreshEpoch, - status: failed.length ? 'partial' : 'accepted', - failedGroups: [...failed], - }; - console.log(`[Data:Satellites] ${_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). - _denseLoadPromise = _params.catalog === 'dense' - ? _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 _reconcileTrackedSubjectContext(); - _applyPendingTrackingRestore(); - - } catch (e) { - if (updateSignal.aborted || e?.name === 'AbortError') { - throw new DOMException('Satellite update aborted', 'AbortError'); - } - console.warn('[Data:Satellites] Fetch error:', e); - } finally { - _activeUpdateControllers.delete(resourceController); - } - }, - - destroy(viewer) { - _abortActiveUpdates(); - releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) - _enabled = false; - _clearTracking(); - _cancelPendingTrackingRestore(); - if (_clickHandler) { - _clickHandler.destroy(); - _clickHandler = null; - } - if (_trackedEntityChangedRemove) { - _trackedEntityChangedRemove(); - _trackedEntityChangedRemove = null; - } - document.removeEventListener('keydown', _onKeyDown); - unregisterPickOwner('satellites'); - if (_preRenderListener) { - _preRenderListener(); - _preRenderListener = null; - } - _overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID); - _overlayHost.setVisible(ISS_OVERLAY_SOURCE_ID, false); - if (_pointCollection) { - viewer.scene.primitives.remove(_pointCollection); - _pointCollection = null; - } - // Orbit ring primitives are removed (and destroyed) here - for (const path of _orbitPaths.values()) { - viewer.scene.primitives.remove(path.primitive); - } - _points.clear(); - _detectionObjects.clear(); - _orbitPaths.clear(); - _catalog.clear(); - _denseIds = []; - _denseCursor = 0; - _denseLoadToken++; - _denseStatus = 'idle'; - _denseError = null; - _catalogRevision++; - _rowControlsListener = null; - _count = 0; - _lastUpdate = null; - _lastError = null; - _lastFocusUpdate = 0; - _activeFocusCount = 0; - _trackingRefreshEpoch += 1; - _lastTrackingRefreshOutcome = { - epoch: _trackingRefreshEpoch, - status: 'destroyed', - failedGroups: [], - }; - _viewer = null; - }, - - getDetectableObjects(options = {}) { - if (!_pointCollection || !_pointCollection.show) return []; - // Dense extras are points-only: excluded from the detection overlay. - const eligibleCount = Math.max(1, _points.size - _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 _points) { - if (_denseIds.length > 0 && _catalog.get(noradId)?.group === 'dense') continue; - const shouldTake = ((idx - start) % stride) === 0; - idx++; - if (!shouldTake) continue; - if (!point.position) continue; - const isTracked = noradId === _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 && _dockedCompanions.has(noradId)) continue; - const cat = _catalog.get(noradId); - let object = _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 }), - }; - _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 || !_catalog || _catalog.size === 0) return null; - const q = String(query).trim(); - if (!q) return null; - - let noradId = null; - if (/^\d+$/.test(q) && _catalog.has(Number(q))) { - noradId = Number(q); - } else { - const lower = q.toLowerCase(); - for (const [id, sat] of _catalog) { - if (sat.name.toLowerCase().includes(lower)) { - noradId = id; - break; - } - } - } - if (noradId === null) return null; - - const sat = _catalog.get(noradId); - const pos = 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 (!_points || _points.size === 0) return result; - const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 300; - - for (const [noradId, point] of _points) { - if (result.length >= cap) break; - if (!point.position) continue; - // Dense extras are points-only — keep voice/framing lists to the core catalog. - if (_denseIds.length > 0 && _catalog.get(noradId)?.group === 'dense') continue; - const carto = Cesium.Cartographic.fromCartesian(point.position); - if (!carto) continue; - const sat = _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) || !_viewer || !_catalog.has(id) || !_points.has(id)) return false; - _cancelPendingTrackingRestore(); - _trackSatellite(id, { origin }); - return _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 = _normalizeTrackedNorad(noradId); - if (id === null) return { status: 'missing', reason: 'invalid-target' }; - const outcome = _lastTrackingRefreshOutcome; - const found = () => _catalog.has(id) && _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 (_params.catalog === 'dense') { - const dense = await (_denseLoadPromise || Promise.resolve({ status: 'source-unavailable' })); - if (signal?.aborted) return { status: 'cancelled', reason: String(signal.reason || 'aborted') }; - if (_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' } = {}) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin }); - return true; - }, - - cancelPendingTrackingRestore() { - _cancelPendingTrackingRestore(); - }, - - /** - * Get info about the currently tracked satellite. - * @returns {{ noradId: number, name: string, latitude: number, longitude: number, altitudeM: number }|null} - */ - getTrackedInfo() { - if (_trackedNorad === null || !_catalog.has(_trackedNorad)) return null; - const sat = _catalog.get(_trackedNorad); - // Per-frame cache (WS-D2) — same epoch as the dot/label/camera this frame. - const pos = _getTrackedFramePosition(); - if (!pos) return null; - return { - noradId: _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')) { - _cancelPendingTrackingRestore(); - } - const catalog = params.catalog; - if (catalog !== undefined && catalog !== 'core' && catalog !== 'dense') return false; - const catalogChanged = satelliteCatalogModeChanged(_params.catalog, catalog); - if (catalogChanged) { - _params.catalog = catalog; - } - if (params.showPoints !== undefined) { - _params.showPoints = params.showPoints !== false; - if (_pointCollection) _pointCollection.show = satelliteVisualsVisible(_enabled, _params.showPoints); - } - if (params.showOrbits !== undefined) { - _params.showOrbits = params.showOrbits !== false; - for (const path of _orbitPaths.values()) path.primitive.show = satelliteVisualsVisible(_enabled, _params.showOrbits); - _syncIssOverlay(); - } - if (catalogChanged && catalog === 'dense') { - _denseLoadPromise = _loadDenseCatalog(); - } else if (catalog === 'core') { - if (catalogChanged) _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. - _denseStatus = 'idle'; - _denseError = null; - } - if (catalogChanged) console.log(`[Data:Satellites] Catalog mode: ${catalog}`); - if (Object.hasOwn(params, 'selectedSatTrackingId')) { - const requested = _normalizeTrackedNorad(params.selectedSatTrackingId); - if (requested === _trackedNorad) { - _pendingTrackingRestore = null; - } else if (requested === null) { - _cancelPendingTrackingRestore(); - if (_trackedNorad !== null) _clearTracking(false, { origin }); - } else { - const generation = ++_trackingIntentGeneration; - _pendingTrackingRestore = { id: requested, generation, origin }; - if (_trackedNorad !== null) _clearTracking(false, { origin }); - _applyPendingTrackingRestore(); - } - } - return true; - }, - - /** @returns {{ catalog: string }} Current runtime params. */ - getParams() { - return { - catalog: _params.catalog, - showPoints: _params.showPoints, - showOrbits: _params.showOrbits, - selectedSatTrackingId: _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, legend: Array }} 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 (!_params.showPoints) return { chips: [], legend: [] }; - - const loading = _denseStatus === 'loading'; - const failed = _denseStatus === 'failed'; - const active = _params.catalog === 'dense' && _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 ${_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) { - _rowControlsListener = typeof listener === 'function' ? listener : null; - }, - - getStats() { - return { - count: _count, - lastUpdate: _lastUpdate, - stale: false, - status: _lastError === 'CelesTrak unreachable' - ? 'unavailable' - : (_lastError ? 'degraded' : 'nominal'), - error: _lastError, - }; - }, -}; - -function _onKeyDown(e) { - if (_enabled && e.key === 'Escape' && _trackedNorad) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } -} - -function _installClickHandler(viewer) { - if (_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 (!_trackedEntityChangedRemove) { - _trackedEntityChangedRemove = viewer.trackedEntityChanged.addEventListener(() => { - if (!_enabled) return; - if (_trackedNorad && _viewer && _viewer.trackedEntity && _viewer.trackedEntity !== _trackedEntity) { - _clearTracking(true, { - origin: _viewer.trackedEntity?.gevSelectionOrigin || 'programmatic', - }); - } - }); - } - - _clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - _clickHandler.setInputAction((click) => { - if (!_enabled) return; - const picked = viewer.scene.pick(click.position); - - if (picked) { - // Clicking tracked entity itself — ignore - if (picked.id === _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) && _catalog.has(noradId)) { - _cancelPendingTrackingRestore(); - _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 (_trackedNorad) { - _cancelPendingTrackingRestore(); - _clearTracking(false, { origin: 'user' }); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - - document.addEventListener('keydown', _onKeyDown); -} - -/** - * 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}}} - */ -export function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) { - const sat = _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. - */ -export 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; -} - -let _lookupTleText = null; -let _lookupTleEntries = []; - -function lookupTleEntries(tleText) { - if (tleText !== _lookupTleText) { - _lookupTleText = tleText; - _lookupTleEntries = parseTLE(tleText); - } - return _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} - */ -export 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} - */ -export function getSatelliteOrbitTrack(query, options = {}) { - if (query === null || query === undefined || !_catalog?.size) return null; - const q = String(query).trim().toLowerCase(); - let noradId = /^\d+$/.test(q) ? Number(q) : null; - if (noradId === null || !_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 _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 = _catalog.get(noradId); - return orbitTrackFromRecord(sat.name, sat.satrec); -} - -export default satellitesLayer; +export const satelliteVisualsVisible = layer.satelliteVisualsVisible; +export const satelliteCatalogModeChanged = layer.satelliteCatalogModeChanged; +export const createIssOverlayEntry = layer.createIssOverlayEntry; +export const orbitFrameModelMatrix = layer.orbitFrameModelMatrix; +export const _setTrackedSatelliteRefreshStateForTest = + layer._setTrackedSatelliteRefreshStateForTest; +export const _setSatelliteTrackingRefreshOutcomeForTest = + layer._setSatelliteTrackingRefreshOutcomeForTest; +export const _trackedFrameCartesianForTest = + layer._trackedFrameCartesianForTest; +export const _runSatellitePreRenderForTest = + layer._runSatellitePreRenderForTest; +export const _setDenseCatalogStateForTest = layer._setDenseCatalogStateForTest; +export const _clearDenseCatalogStateForTest = + layer._clearDenseCatalogStateForTest; +export const _catalogGroupForTest = layer._catalogGroupForTest; +export const _setSatelliteLabelLifecycleStateForTest = + layer._setSatelliteLabelLifecycleStateForTest; +export const _trackIssForTest = layer._trackIssForTest; +export const _pendingSatelliteTrackingRestoreForTest = + layer._pendingSatelliteTrackingRestoreForTest; +export const _applyPendingSatelliteTrackingRestoreForTest = + layer._applyPendingSatelliteTrackingRestoreForTest; +export const _removeSatelliteTrackingCandidateForTest = + layer._removeSatelliteTrackingCandidateForTest; +export const _clearSatelliteLabelLifecycleForTest = + layer._clearSatelliteLabelLifecycleForTest; +export const applySatellitePointFocusDeemphasis = + layer.applySatellitePointFocusDeemphasis; +export const getNextIssPass = layer.getNextIssPass; +export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch; +export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle; +export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack; +export { + ISS_OVERLAY_SOURCE_ID, + ISS_OVERLAY_SOURCE_OPTIONS, +} from '../layers/satellites/index.js'; +export default layer; diff --git a/src/layers/launches/camera.js b/src/layers/launches/camera.js new file mode 100644 index 000000000..e6a1ef8c9 --- /dev/null +++ b/src/layers/launches/camera.js @@ -0,0 +1,375 @@ +import * as Cesium from 'cesium'; +import { + REPLAY_ASCENT_CAMERA_OFFSET_RAD, + REPLAY_ORBIT_CAMERA_OFFSET_RAD, + MISSION_CLOSE_VIEW_RANGE_M, + MISSION_GLOBE_VIEW_RANGE_M, + REPLAY_INITIAL_RANGE_M, + REPLAY_LOCAL_MAX_RANGE_M, + REPLAY_CONTEXT_ALTITUDE_END_M, + REPLAY_CONTEXT_MAX_RANGE_M, + REPLAY_ORBIT_PULLBACK_FRACTION, + REPLAY_ORBIT_GLOBE_RANGE_M, + REPLAY_ORBIT_FRAME_CENTER_BLEND, +} from './policy.js'; + +export function createCamera({ state: layerState, services, parts, source }) { + function cameraHeadingForPath(path, progress, fallback = Math.PI) { + if (!path?.length) return fallback; + const current = parts.paths.samplePath(path, progress); + const currentCartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(current); + if (!currentCartographic) return fallback; + for (const step of [0.01, 0.025, 0.05, 0.1, 0.2]) { + const next = parts.paths.samplePath(path, Math.min(1, progress + step)); + const nextCartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(next); + if (!nextCartographic) continue; + const geodesic = new Cesium.EllipsoidGeodesic( + currentCartographic, + nextCartographic, + ); + if ( + geodesic.surfaceDistance > 10 && + Number.isFinite(geodesic.startHeading) + ) { + // HeadingPitchRange positions the camera opposite its heading vector. + // Passing the forward path heading therefore keeps the camera behind + // the vehicle, with the remaining ascent receding into the scene. + return Cesium.Math.zeroToTwoPi(geodesic.startHeading); + } + } + return fallback; + } + + /** + * Resolve the initial replay heading as a profile view of the path. + * @param {Cesium.Cartesian3[]} path Replay path. + * @returns {number} Heading perpendicular to the initial path direction. + */ + + function replayInitialCameraHeading(path) { + return Cesium.Math.zeroToTwoPi( + cameraHeadingForPath(path, 0, Math.PI) + Cesium.Math.PI_OVER_TWO, + ); + } + + /** + * Keep ascent framing behind and slightly to one side of the vehicle, then + * widen that rear-quarter angle as the orbit becomes visible. + * @param {number} pathHeading Forward path bearing in radians. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @returns {number} Cesium HeadingPitchRange heading. + */ + + function replayChaseCameraHeading(pathHeading, orbitBlend = 0) { + const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); + return Cesium.Math.zeroToTwoPi( + pathHeading + + Cesium.Math.lerp( + REPLAY_ASCENT_CAMERA_OFFSET_RAD, + REPLAY_ORBIT_CAMERA_OFFSET_RAD, + blend, + ), + ); + } + + /** + * Limit replay-camera yaw changes so a path heading wrap or insertion turn + * cannot swing the chase view through the front of the vehicle. + * @param {number} previous Previous Cesium heading in radians. + * @param {number} desired Desired Cesium heading in radians. + * @param {number} [maxStepRad] Maximum angular change for one rendered frame. + * @returns {number} + */ + + function smoothReplayCameraHeading( + previous, + desired, + maxStepRad = Cesium.Math.toRadians(2), + ) { + if (!Number.isFinite(previous)) return Cesium.Math.zeroToTwoPi(desired); + if (!Number.isFinite(desired)) return Cesium.Math.zeroToTwoPi(previous); + const delta = Cesium.Math.negativePiToPi(desired - previous); + const step = Cesium.Math.clamp( + delta, + -Math.abs(maxStepRad), + Math.abs(maxStepRad), + ); + return Cesium.Math.zeroToTwoPi(previous + step); + } + + /** + * Blend from a global nadir view into an oblique local 3D view as the user + * approaches a selected launch site. + * @param {number} rangeM Camera distance from the launch-site anchor. + * @returns {number} Cesium camera pitch in radians. + */ + + function missionZoomPitch(rangeM) { + const range = Math.max(0, Number(rangeM) || 0); + const blend = Cesium.Math.clamp( + (Math.log(Math.max(range, MISSION_CLOSE_VIEW_RANGE_M)) - + Math.log(MISSION_CLOSE_VIEW_RANGE_M)) / + (Math.log(MISSION_GLOBE_VIEW_RANGE_M) - + Math.log(MISSION_CLOSE_VIEW_RANGE_M)), + 0, + 1, + ); + return Cesium.Math.lerp( + Cesium.Math.toRadians(-42), + -Cesium.Math.PI_OVER_TWO, + blend, + ); + } + + /** + * Resolve the replay camera offset for either close ascent tracking or the + * orbital globe pullback. + * @param {{ascending: boolean, phaseProgress: number}} state Replay phase. + * @param {number} altitudeM Animated vehicle altitude above the ellipsoid. + * @returns {{range: number, pitch: number}} + */ + + function replayCameraView(state, altitudeM) { + const altitude = Math.max(0, Number(altitudeM) || 0); + const localRange = Cesium.Math.clamp( + REPLAY_INITIAL_RANGE_M + altitude * 0.7, + REPLAY_INITIAL_RANGE_M, + REPLAY_LOCAL_MAX_RANGE_M, + ); + const rawContextBlend = Cesium.Math.clamp( + (altitude - 20000) / (REPLAY_CONTEXT_ALTITUDE_END_M - 20000), + 0, + 1, + ); + const contextBlend = + rawContextBlend * rawContextBlend * (3 - 2 * rawContextBlend); + const contextRange = Cesium.Math.clamp( + 180000 + altitude * 3.8, + MISSION_CLOSE_VIEW_RANGE_M, + REPLAY_CONTEXT_MAX_RANGE_M, + ); + const ascentRange = Cesium.Math.lerp( + localRange, + contextRange, + contextBlend, + ); + const ascentPitch = Cesium.Math.lerp( + Cesium.Math.toRadians(-20), + Cesium.Math.toRadians(-34), + contextBlend, + ); + if (state?.ascending) { + return { range: ascentRange, pitch: ascentPitch }; + } + const rawBlend = Cesium.Math.clamp( + (Number(state?.phaseProgress) || 0) / REPLAY_ORBIT_PULLBACK_FRACTION, + 0, + 1, + ); + const blend = rawBlend * rawBlend * (3 - 2 * rawBlend); + return { + range: Cesium.Math.lerp(ascentRange, REPLAY_ORBIT_GLOBE_RANGE_M, blend), + // Keep an oblique tactical view of the complete orbit rather than ending + // in a nadir view. This also prevents the camera from appearing to + // return toward the launch site after insertion. + pitch: Cesium.Math.lerp(ascentPitch, Cesium.Math.toRadians(-45), blend), + }; + } + + /** + * Move the orbit-follow target from the vehicle toward its sub-satellite + * globe anchor, keeping Earth centered while the vehicle remains in frame. + * @param {Cesium.Cartesian3} position Animated orbital position. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @returns {Cesium.Cartesian3} Camera look-at target. + */ + + function replayOrbitGlobeAnchor(position, orbitBlend = 0) { + if (!position) return position; + const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); + if (blend <= 0) return Cesium.Cartesian3.clone(position); + const cartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); + if (!cartographic) return Cesium.Cartesian3.clone(position); + const altitude = Math.max(0, cartographic.height || 0); + return Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + Cesium.Math.lerp(altitude, altitude * 0.1, blend), + ); + } + + /** + * Keep the orbital camera's look-at frame biased toward the moving vehicle. + * Blending completely to a whole-orbit bounding-sphere center can place the + * target at (or numerically close to) Earth's center. That frame is singular + * for a heading/pitch camera and lets compact-orbit vehicles leave the view as + * the camera rotates. Retaining a radial vehicle bias keeps the local frame + * stable while the wider camera range still contains Earth and the full orbit. + * @param {Cesium.Cartesian3} vehicleAnchor Globe-side anchor below the vehicle. + * @param {Cesium.Cartesian3|null} frameCenter Combined Earth/orbit frame center. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @returns {Cesium.Cartesian3} Stable camera look-at target. + */ + + function replayOrbitCameraTarget(vehicleAnchor, frameCenter, orbitBlend = 0) { + if (!vehicleAnchor) return vehicleAnchor; + if (!frameCenter) return Cesium.Cartesian3.clone(vehicleAnchor); + const blend = + Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1) * + REPLAY_ORBIT_FRAME_CENTER_BLEND; + return Cesium.Cartesian3.lerp( + vehicleAnchor, + frameCenter, + blend, + new Cesium.Cartesian3(), + ); + } + + /** + * Build a stable orbit-relative camera pose. The camera remains on one side + * of the orbital plane and uses the vehicle radial as its visual up axis. + * Consequently the forward orbit tangent always projects toward screen-left + * instead of changing direction when local compass headings wrap near a pole. + * @param {Cesium.Cartesian3} position Current vehicle position. + * @param {Cesium.Cartesian3} tangentPosition Nearby forward path position. + * @param {Cesium.Cartesian3} target Camera look-at target. + * @param {number} range Camera distance from the target. + * @param {number} pitch Camera elevation below the local horizon. + * @returns {{destination: Cesium.Cartesian3, direction: Cesium.Cartesian3, up: Cesium.Cartesian3}|null} + */ + + function replayOrbitCameraPose( + position, + tangentPosition, + target, + range, + pitch, + ) { + if (!position || !tangentPosition || !target) return null; + const radial = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.clone(position), + new Cesium.Cartesian3(), + ); + const tangent = Cesium.Cartesian3.subtract( + tangentPosition, + position, + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitudeSquared(tangent) < 1) return null; + Cesium.Cartesian3.normalize(tangent, tangent); + const orbitNormal = Cesium.Cartesian3.cross( + radial, + tangent, + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitudeSquared(orbitNormal) < 1e-12) return null; + Cesium.Cartesian3.normalize(orbitNormal, orbitNormal); + + const distance = Math.max(1, Number(range) || 1); + const elevation = Cesium.Math.clamp( + Math.abs(Number(pitch) || 0), + Cesium.Math.toRadians(5), + Cesium.Math.toRadians(80), + ); + const planeOffset = Cesium.Cartesian3.multiplyByScalar( + orbitNormal, + Math.cos(elevation) * distance, + new Cesium.Cartesian3(), + ); + const radialOffset = Cesium.Cartesian3.multiplyByScalar( + radial, + Math.sin(elevation) * distance, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + target, + planeOffset, + new Cesium.Cartesian3(), + ); + Cesium.Cartesian3.add(destination, radialOffset, destination); + const direction = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(target, destination, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const radialAlongView = Cesium.Cartesian3.multiplyByScalar( + direction, + Cesium.Cartesian3.dot(radial, direction), + new Cesium.Cartesian3(), + ); + const up = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract( + radial, + radialAlongView, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return { destination, direction, up }; + } + + /** + * Build one conservative frame that contains both Earth and the complete + * selected orbit. High-apogee missions cannot be composed from the vehicle's + * instantaneous altitude alone because the opposite side of the orbit may + * extend much farther from the globe. + * @param {Cesium.Cartesian3[]} orbitPath Selected orbit samples. + * @returns {Cesium.BoundingSphere} Combined Earth/orbit frame. + */ + + function replayOrbitFrameSphere(orbitPath = []) { + const earth = new Cesium.BoundingSphere( + Cesium.Cartesian3.ZERO, + Cesium.Ellipsoid.WGS84.maximumRadius, + ); + if (!Array.isArray(orbitPath) || orbitPath.length < 2) return earth; + const orbit = Cesium.BoundingSphere.fromPoints(orbitPath); + return Cesium.BoundingSphere.union( + earth, + orbit, + new Cesium.BoundingSphere(), + ); + } + + /** + * Ensure high-altitude missions frame both the globe and selected vehicle. + * @param {number} baseRange Range from the normal replay camera transition. + * @param {number} altitudeM Vehicle altitude above the ellipsoid. + * @param {number} orbitBlend Normalized orbit-camera transition. + * @param {number} frameRadiusM Radius of the combined Earth/orbit frame. + * @returns {number} Camera range in metres. + */ + + function replayOrbitGlobeRange( + baseRange, + altitudeM, + orbitBlend = 0, + frameRadiusM = 0, + ) { + const range = Math.max(0, Number(baseRange) || 0); + const altitude = Math.max(0, Number(altitudeM) || 0); + const frameRadius = Math.max(0, Number(frameRadiusM) || 0); + const blend = Cesium.Math.clamp(Number(orbitBlend) || 0, 0, 1); + const globeAndVehicleRange = Math.max( + range, + altitude + Cesium.Ellipsoid.WGS84.maximumRadius * 2.4, + frameRadius * 3, + ); + return Cesium.Math.lerp(range, globeAndVehicleRange, blend); + } + return { + cameraHeadingForPath, + replayInitialCameraHeading, + replayChaseCameraHeading, + smoothReplayCameraHeading, + missionZoomPitch, + replayCameraView, + replayOrbitGlobeAnchor, + replayOrbitCameraTarget, + replayOrbitCameraPose, + replayOrbitFrameSphere, + replayOrbitGlobeRange, + }; +} diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js new file mode 100644 index 000000000..1d119ebdb --- /dev/null +++ b/src/layers/launches/controls.js @@ -0,0 +1,35 @@ +export function createControls({ state: layerState, services, parts, source }) { + const methods = { + id: 'rocket-launches', + + name: 'Space Missions (30d)', + + icon: '🚀', + + source: 'Launch Library 2', + + updateInterval: 300000, + + /** Release only Space Mission camera ownership, preserving layer and selection state. */ + releaseCameraOwnership() { + parts.panel.clearMissionRosterHover(); + parts.replay.stopMissionReplay(); + parts.selection.stopMissionZoomAnchor(); + }, + + getStats() { + return { + count: layerState._count, + orbitMatches: layerState._orbitMatches, + lastUpdate: layerState._lastUpdate, + error: layerState._lastError, + }; + }, + + attachDataManager(dataManager) { + layerState._dataManager = dataManager; + }, + }; + + return { methods }; +} diff --git a/src/layers/launches/index.js b/src/layers/launches/index.js new file mode 100644 index 000000000..8df45903e --- /dev/null +++ b/src/layers/launches/index.js @@ -0,0 +1,118 @@ +import { createPolicyHelpers } from './policyHelpers.js'; +import { createIngestion } from './ingestion.js'; +import { createOrbitRendering } from './orbitRendering.js'; +import { createOverlays } from './overlays.js'; +import { createSelection } from './selection.js'; +import { createReplay } from './replay.js'; +import { createPanel } from './panel.js'; +import { createPaths } from './paths.js'; +import { createLaunchPad } from './launchPad.js'; +import { createCamera } from './camera.js'; +import { createModel } from './model.js'; +import { createRendering } from './rendering.js'; +import { createTesting } from './testing.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createState } from './state.js'; + +/** Construct one layer with its own scene state and supplied application services. */ +export function createRocketLaunchesLayer({ services, source }) { + if (typeof source?.getLaunches !== 'function') + throw new TypeError('A launches source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, services, parts, source }; + parts.policyHelpers = createPolicyHelpers(context); + parts.ingestion = createIngestion(context); + parts.orbitRendering = createOrbitRendering(context); + parts.overlays = createOverlays(context); + parts.selection = createSelection(context); + parts.replay = createReplay(context); + parts.panel = createPanel(context); + parts.paths = createPaths(context); + parts.launchPad = createLaunchPad(context); + parts.camera = createCamera(context); + parts.model = createModel(context); + parts.rendering = createRendering(context); + parts.testing = createTesting(context); + parts.controls = createControls(context); + parts.lifecycle = createLifecycle(context); + return Object.assign( + {}, + parts.controls.methods, + parts.lifecycle.methods, + parts.ingestion.methods, + { + satelliteParamsForSpaceMissions: + parts.policyHelpers.satelliteParamsForSpaceMissions, + satelliteParamsAfterSpaceMissions: + parts.policyHelpers.satelliteParamsAfterSpaceMissions, + launchStatusAllowsOrbit: parts.policyHelpers.launchStatusAllowsOrbit, + missionPathPresentation: parts.policyHelpers.missionPathPresentation, + shouldRetryAfterActiveTle: parts.ingestion.shouldRetryAfterActiveTle, + releaseAircraftTracking: parts.policyHelpers.releaseAircraftTracking, + launchPadZoneVisible: parts.policyHelpers.launchPadZoneVisible, + missionAnchorHorizonVisible: parts.overlays.missionAnchorHorizonVisible, + missionAnchorVisible: parts.overlays.missionAnchorVisible, + compactLaunchSiteName: parts.overlays.compactLaunchSiteName, + createRocketMissionMarkerOverlayEntry: + parts.overlays.createRocketMissionMarkerOverlayEntry, + createRocketMissionElementOverlayEntry: + parts.overlays.createRocketMissionElementOverlayEntry, + selectRocketMissionMarkerOverlayCohort: + parts.overlays.selectRocketMissionMarkerOverlayCohort, + replayOverlayMode: parts.overlays.replayOverlayMode, + replayVehicleScreenRotation: parts.replay.replayVehicleScreenRotation, + smoothReplayWindowPosition: parts.replay.smoothReplayWindowPosition, + missionDataCompleteness: parts.policyHelpers.missionDataCompleteness, + missionRosterEntries: parts.policyHelpers.missionRosterEntries, + missionHoverPreviewRange: parts.panel.missionHoverPreviewRange, + createMissionRosterPreviewOwnership: + parts.panel.createMissionRosterPreviewOwnership, + bindMissionRosterItemKeyboardPreview: + parts.panel.bindMissionRosterItemKeyboardPreview, + captureMissionRosterFocus: parts.panel.captureMissionRosterFocus, + restoreMissionRosterFocus: parts.panel.restoreMissionRosterFocus, + resolveMissionRosterPreviewLaunch: + parts.panel.resolveMissionRosterPreviewLaunch, + formatMissionEventTime: parts.policyHelpers.formatMissionEventTime, + parseMissionDurationSeconds: + parts.policyHelpers.parseMissionDurationSeconds, + replayAscentDurationSeconds: parts.replay.replayAscentDurationSeconds, + normalizeReplaySpeed: parts.replay.normalizeReplaySpeed, + replayStartAfterPause: parts.replay.replayStartAfterPause, + replayState: parts.replay.replayState, + approximateOrbitPath: parts.paths.approximateOrbitPath, + samplePath: parts.paths.samplePath, + orbitProgressAtTime: parts.paths.orbitProgressAtTime, + reconstructedAscentPath: parts.paths.reconstructedAscentPath, + buildMissionPaths: parts.paths.buildMissionPaths, + cameraHeadingForPath: parts.camera.cameraHeadingForPath, + replayInitialCameraHeading: parts.camera.replayInitialCameraHeading, + replayChaseCameraHeading: parts.camera.replayChaseCameraHeading, + smoothReplayCameraHeading: parts.camera.smoothReplayCameraHeading, + missionZoomPitch: parts.camera.missionZoomPitch, + replayCameraView: parts.camera.replayCameraView, + replayOrbitGlobeAnchor: parts.camera.replayOrbitGlobeAnchor, + replayOrbitCameraTarget: parts.camera.replayOrbitCameraTarget, + replayOrbitCameraPose: parts.camera.replayOrbitCameraPose, + replayOrbitFrameSphere: parts.camera.replayOrbitFrameSphere, + replayOrbitGlobeRange: parts.camera.replayOrbitGlobeRange, + missionMarkerColor: parts.model.missionMarkerColor, + normalizeRocketLaunches: parts.model.normalizeRocketLaunches, + _setRocketMissionOverlayHostForTest: + parts.testing._setRocketMissionOverlayHostForTest, + _setSelectedRocketMissionForTest: + parts.testing._setSelectedRocketMissionForTest, + }, + ); +} +export { + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + LAUNCH_PAD_ZONE_RADIUS_M, +} from './policy.js'; +export { createLaunchSource } from './source.js'; diff --git a/src/layers/launches/ingestion.js b/src/layers/launches/ingestion.js new file mode 100644 index 000000000..e7eab0565 --- /dev/null +++ b/src/layers/launches/ingestion.js @@ -0,0 +1,268 @@ +import { MAX_POST_TLE_RETRIES, POST_TLE_RETRY_DELAY_MS } from './policy.js'; + +export function createIngestion({ + state: layerState, + services, + parts, + source, +}) { + /** + * Decide whether one bounded rebuild is needed after the active TLE lookup. + * @param {object} input Retry state. + * @param {boolean} input.enabled Whether Space Missions is active. + * @param {number} input.retryCount Number of post-TLE retries already used. + * @param {string|null} input.activeTleText Resolved active TLE catalog. + * @param {string|null} input.renderedTleText TLE catalog used for the last build. + * @returns {boolean} Whether to schedule a refresh. + */ + + function shouldRetryAfterActiveTle({ + enabled, + retryCount, + activeTleText, + renderedTleText, + }) { + return Boolean( + enabled && + activeTleText && + activeTleText !== renderedTleText && + retryCount < MAX_POST_TLE_RETRIES, + ); + } + + function clearPostTleRetry() { + if (layerState._retryTimer) clearTimeout(layerState._retryTimer); + layerState._retryTimer = null; + } + + function schedulePostTleRetry(token) { + if ( + layerState._retryTimer || + token !== layerState._lifecycleToken || + !shouldRetryAfterActiveTle({ + enabled: layerState._enabled, + retryCount: layerState._postTleRetryCount, + activeTleText: layerState._activeTleText, + renderedTleText: layerState._renderedTleText, + }) + ) + return; + layerState._postTleRetryCount++; + layerState._retryTimer = setTimeout(() => { + layerState._retryTimer = null; + if (layerState._enabled && token === layerState._lifecycleToken) + requestMissionUpdate(); + }, POST_TLE_RETRY_DELAY_MS); + } + + function ensureActiveTleLookup(token) { + if (layerState._activeTleText) + return Promise.resolve(layerState._activeTleText); + if ( + layerState._activeTlePromise && + layerState._activeTlePromiseToken === token + ) + return layerState._activeTlePromise; + const request = source + .getActiveTle({ signal: layerState._sourceController.signal }) + .then((text) => { + if (!layerState._enabled || token !== layerState._lifecycleToken) + return null; + layerState._activeTleText = text; + layerState._focusAfterActiveLookup = Boolean( + layerState._selectedLaunchId, + ); + schedulePostTleRetry(token); + return text; + }) + .catch((error) => { + if (layerState._enabled && token === layerState._lifecycleToken) { + console.warn( + '[Data:RocketLaunches] Active satellite lookup unavailable:', + error.message, + ); + } + return null; + }) + .finally(() => { + if (layerState._activeTlePromise === request) { + layerState._activeTlePromise = null; + layerState._activeTlePromiseToken = 0; + } + }); + layerState._activeTlePromise = request; + layerState._activeTlePromiseToken = token; + return request; + } + + async function captureSatelliteDependency() { + if (!layerState._dataManager || layerState._satelliteStateBeforeMission) + return; + layerState._satelliteStateBeforeMission = { + // Effective visibility: a user enable still mid-activation is intent ON — + // capturing settled false would restore the user's enable away on exit. + enabled: + layerState._dataManager.isEffectivelyEnabled?.('satellites') ?? + layerState._dataManager.isEnabled('satellites'), + params: parts.policyHelpers.satelliteParamsAfterSpaceMissions( + layerState._dataManager.getLayerParams('satellites'), + ), + }; + layerState._dataManager.setLayerParams( + 'satellites', + parts.policyHelpers.satelliteParamsForSpaceMissions( + layerState._satelliteStateBeforeMission.params, + ), + ); + const token = layerState._lifecycleToken; + const activation = Promise.resolve( + layerState._dataManager.setEnabled('satellites', true), + ); + layerState._satelliteActivationPromise = activation; + try { + const activated = await activation; + // Space Missions without its satellite dependency is a broken replay + // surface; fail the mission enable so the manager's fail-closed path and + // the Context rollback see an honest failure instead of a silent success. + if ( + token === layerState._lifecycleToken && + layerState._enabled && + (activated === false || + !layerState._dataManager.isEnabled('satellites')) + ) { + throw new Error( + 'Space Missions requires the satellites layer, which failed to start', + ); + } + } finally { + if ( + token === layerState._lifecycleToken && + layerState._satelliteActivationPromise === activation + ) { + layerState._satelliteActivationPromise = null; + } + } + } + + async function restoreSatelliteDependency() { + const snapshot = layerState._satelliteStateBeforeMission; + if (!snapshot || !layerState._dataManager) return; + layerState._satelliteStateBeforeMission = null; + layerState._satelliteActivationPromise = null; + layerState._dataManager.setLayerParams( + 'satellites', + parts.policyHelpers.satelliteParamsAfterSpaceMissions(snapshot.params), + ); + const restored = await layerState._dataManager.setEnabled( + 'satellites', + snapshot.enabled, + ); + if ( + restored === false || + layerState._dataManager.isEnabled('satellites') !== snapshot.enabled + ) { + throw new Error('Space Missions could not restore the satellites layer'); + } + } + + async function performMissionUpdate(token) { + try { + ensureActiveTleLookup(token); + const payload = await source.getLaunches({ + signal: layerState._sourceController.signal, + }); + const launches = parts.model.normalizeRocketLaunches(payload); + if ( + !layerState._enabled || + token !== layerState._lifecycleToken || + !layerState._dataSource + ) + return; + const activeTleText = layerState._activeTleText; + if (layerState._replayCameraLaunchId) parts.replay.stopMissionReplay(); + layerState._launches = launches; + parts.orbitRendering.removeMissionOrbitPrimitives(); + layerState._dataSource.entities.removeAll(); + layerState._missionOverlayRecords.clear(); + layerState._satelliteTelemetry.clear(); + layerState._replayTracks.clear(); + layerState._orbitMatches = 0; + launches.forEach((launch) => + parts.rendering.addLaunchEntity(launch, activeTleText), + ); + layerState._renderedTleText = activeTleText; + if (layerState._renderedTleText === layerState._activeTleText) + clearPostTleRetry(); + layerState._count = launches.length; + parts.panel.renderMissionRoster(); + if ( + !layerState._selectedLaunchId || + !launches.some((launch) => launch.id === layerState._selectedLaunchId) + ) { + parts.selection.setSelectedMission(null, false); + } else { + parts.selection.setSelectedMission(layerState._selectedLaunchId, true); + if (layerState._focusAfterActiveLookup) { + parts.selection.focusMission( + launches.find( + (launch) => launch.id === layerState._selectedLaunchId, + ), + ); + } + } + layerState._focusAfterActiveLookup = false; + parts.panel.renderMissionPanel(); + layerState._lastUpdate = Date.now(); + layerState._lastError = null; + } catch (error) { + if (layerState._enabled && token === layerState._lifecycleToken) { + layerState._lastError = error.message; + console.warn('[Data:RocketLaunches] Fetch error:', error); + } + } + } + + function requestMissionUpdate() { + if (!layerState._enabled) return Promise.resolve(); + const token = layerState._lifecycleToken; + layerState._updateDirty = true; + if (layerState._updatePromise && layerState._updatePromiseToken === token) + return layerState._updatePromise; + let request; + request = (async () => { + while ( + layerState._enabled && + token === layerState._lifecycleToken && + layerState._updateDirty + ) { + layerState._updateDirty = false; + await performMissionUpdate(token); + } + })().finally(() => { + if (layerState._updatePromise === request) { + layerState._updatePromise = null; + layerState._updatePromiseToken = 0; + } + }); + layerState._updatePromise = request; + layerState._updatePromiseToken = token; + return request; + } + const methods = { + update() { + return requestMissionUpdate(); + }, + }; + + return { + shouldRetryAfterActiveTle, + clearPostTleRetry, + schedulePostTleRetry, + ensureActiveTleLookup, + captureSatelliteDependency, + restoreSatelliteDependency, + performMissionUpdate, + requestMissionUpdate, + methods, + }; +} diff --git a/src/layers/launches/launchPad.js b/src/layers/launches/launchPad.js new file mode 100644 index 000000000..f0a87ce6a --- /dev/null +++ b/src/layers/launches/launchPad.js @@ -0,0 +1,158 @@ +import * as Cesium from 'cesium'; +import { LAUNCH_PAD_ZONE_RADIUS_M } from './policy.js'; + +export function createLaunchPad({ + state: layerState, + services, + parts, + source, +}) { + function hideLaunchPadZone() { + if (layerState._launchPadZonePrimitive) + layerState._launchPadZonePrimitive.show = false; + } + + function removeLaunchPadZonePrimitive() { + if ( + layerState._launchPadZonePrimitive && + layerState._viewer?.scene?.primitives + ) { + layerState._viewer.scene.primitives.remove( + layerState._launchPadZonePrimitive, + ); + } + layerState._launchPadZonePrimitive = null; + layerState._launchPadZoneLaunchId = null; + } + + function createLaunchPadZonePrimitive(launch) { + removeLaunchPadZonePrimitive(); + const material = new Cesium.Material({ + fabric: { + type: 'GevLaunchPadZone', + uniforms: { + color: Cesium.Color.fromCssColorString('#22e6e6'), + fillAlpha: 0.105, + rimAlpha: 0.72, + }, + source: ` + czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + vec2 centered = (materialInput.st - vec2(0.5)) * 2.0; + float radius = length(centered); + float inside = 1.0 - smoothstep(0.985, 1.0, radius); + float rim = smoothstep(0.952, 0.985, radius) * inside; + material.diffuse = color.rgb; + material.emission = color.rgb * rim * 0.35; + material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim); + return material; + }`, + }, + }); + const geometry = new Cesium.EllipseGeometry({ + center: Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat), + semiMajorAxis: LAUNCH_PAD_ZONE_RADIUS_M, + semiMinorAxis: LAUNCH_PAD_ZONE_RADIUS_M, + granularity: Cesium.Math.toRadians(0.08), + vertexFormat: + Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat, + }); + layerState._launchPadZonePrimitive = + layerState._viewer.scene.primitives.add( + new Cesium.GroundPrimitive({ + geometryInstances: new Cesium.GeometryInstance({ + geometry, + id: `rocket-pad-zone:${launch.id}`, + }), + appearance: new Cesium.MaterialAppearance({ + material, + translucent: true, + closed: false, + faceForward: true, + flat: true, + // Keep the zone classified onto the photoreal surface, but bias only its + // rasterized depth toward the camera. This avoids coplanar fragments + // being intermittently buried by the launch-pad mesh at oblique angles + // without adding a world-space height that would make the ring float. + renderState: { + depthTest: { + enabled: true, + }, + depthMask: false, + polygonOffset: { + enabled: true, + factor: -1, + units: -4, + }, + blending: Cesium.BlendingState.ALPHA_BLEND, + }, + }), + classificationType: Cesium.ClassificationType.BOTH, + asynchronous: true, + show: true, + }), + ); + layerState._launchPadZoneLaunchId = launch.id; + } + + function initLaunchPadZonePrimitive() { + if (!layerState._viewer || layerState._launchPadZoneRemover) return; + layerState._launchPadZoneRemover = + layerState._viewer.scene.preRender.addEventListener(() => { + if (!layerState._enabled || !layerState._dataSource?.show) { + hideLaunchPadZone(); + return; + } + const nowMs = Date.now(); + if (nowMs - layerState._lastMissionRingRotationMs >= 1000) { + layerState._missionRingDate.setTime(nowMs); + parts.orbitRendering.updateMissionOrbitPrimitiveFrames( + layerState._missionRingDate, + ); + layerState._lastMissionRingRotationMs = nowMs; + } + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + const camera = layerState._viewer?.camera; + if (!launch || !camera) { + hideLaunchPadZone(); + return; + } + const launchPosition = Cesium.Cartesian3.fromDegrees( + launch.lon, + launch.lat, + ); + const visible = parts.policyHelpers.launchPadZoneVisible({ + layerActive: Boolean(layerState._dataSource?.show), + selectedLaunchId: layerState._selectedLaunchId, + launchId: launch.id, + cameraHeightM: camera.positionCartographic?.height, + cameraDistanceM: Cesium.Cartesian3.distance( + camera.positionWC, + launchPosition, + ), + }); + if (!visible) { + hideLaunchPadZone(); + return; + } + if (layerState._launchPadZoneLaunchId !== launch.id) + createLaunchPadZonePrimitive(launch); + layerState._launchPadZonePrimitive.show = true; + }); + } + + function destroyLaunchPadZonePrimitive() { + if (layerState._launchPadZoneRemover) layerState._launchPadZoneRemover(); + layerState._launchPadZoneRemover = null; + removeLaunchPadZonePrimitive(); + } + return { + hideLaunchPadZone, + removeLaunchPadZonePrimitive, + createLaunchPadZonePrimitive, + initLaunchPadZonePrimitive, + destroyLaunchPadZonePrimitive, + }; +} diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js new file mode 100644 index 000000000..2eea788d0 --- /dev/null +++ b/src/layers/launches/lifecycle.js @@ -0,0 +1,172 @@ +import * as Cesium from 'cesium'; + +export function createLifecycle({ + state: layerState, + services, + parts, + source, +}) { + const { holdContinuousRender, releaseContinuousRender } = services.render; + + const methods = { + init(viewer) { + layerState._viewer = viewer; + layerState._enabled = false; + parts.panel.createMissionPanel(); + parts.overlays.createReplayVehicleOverlay(); + layerState._dataSource = new Cesium.CustomDataSource('rocket-launches'); + layerState._dataSource.show = false; + viewer.dataSources.add(layerState._dataSource); + layerState._count = 0; + layerState._lastUpdate = null; + layerState._lastError = null; + layerState._orbitMatches = 0; + parts.overlays.clearMissionOverlaySources(); + layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._clickHandler.setInputAction((movement) => { + if (!layerState._enabled || !layerState._dataSource?.show) return; + const entity = viewer.scene + .drillPick(movement.position, 12) + .map((picked) => picked?.id) + .find((candidate) => parts.selection.entityLaunchId(candidate)); + const launchId = parts.selection.entityLaunchId(entity); + if (!launchId) return; + parts.selection.setSelectedMission(launchId); + parts.selection.focusMission( + layerState._launches.find((launch) => launch.id === launchId), + ); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + layerState._moveHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + layerState._moveHandler.setInputAction((movement) => { + if (!layerState._enabled || !layerState._dataSource?.show) return; + const missionEntity = viewer.scene + .drillPick(movement.endPosition, 12) + .map((picked) => picked?.id) + .find((candidate) => parts.selection.entityLaunchId(candidate)); + viewer.scene.canvas.style.cursor = missionEntity ? 'pointer' : ''; + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + // Apply horizon visibility before Cesium traverses and picks the scene. + // A postRender write is one frame late and can remain visibly stale when + // request-on-demand rendering stops after a globe camera move. + layerState._declutterHandler = viewer.scene.preRender.addEventListener( + parts.rendering.updateMissionFrame, + ); + parts.launchPad.initLaunchPadZonePrimitive(); + }, + + async enable() { + layerState._sourceController.abort(); + layerState._sourceController = new AbortController(); + layerState._enabled = true; + holdContinuousRender('rocket-launches'); // per-frame animator (perf wave 2) + layerState._lifecycleToken++; + layerState._postTleRetryCount = 0; + try { + await this._enableBody(); + } catch (error) { + // Enable is a transaction: a failed dependency must not leave mission + // UI/datasource visible or the satellite snapshot retained (a retained + // snapshot makes the next enable skip dependency capture entirely). + try { + await this.disable(); + } catch (cleanupError) { + console.warn('[Missions] enable rollback failed:', cleanupError); + } + throw error; + } + }, + + async _enableBody() { + layerState._updateDirty = false; + if (layerState._dataSource) layerState._dataSource.show = true; + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + parts.selection.focusFullGlobe(layerState._viewer); + document.getElementById('cockpit-context')?.setAttribute('hidden', ''); + if (layerState._selectedLaunchId) + parts.selection.setSelectedMission( + layerState._selectedLaunchId, + layerState._explicitSelection, + ); + else parts.selection.setSelectedMission(null, false); + await parts.ingestion.captureSatelliteDependency(); + }, + + async disable() { + layerState._sourceController.abort(); + layerState._enabled = false; + releaseContinuousRender('rocket-launches'); + layerState._lifecycleToken++; + layerState._updateDirty = false; + parts.ingestion.clearPostTleRetry(); + parts.panel.clearMissionRosterHover(); + parts.replay.stopMissionReplay(); + parts.selection.stopMissionZoomAnchor(); + if (layerState._dataSource) layerState._dataSource.show = false; + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + if (layerState._viewer?.scene?.canvas) + layerState._viewer.scene.canvas.style.cursor = ''; + parts.launchPad.hideLaunchPadZone(); + parts.overlays.hideReplayVehicleOverlay(); + layerState._selectedLaunchId = null; + parts.overlays.clearMissionOverlaySources(); + await parts.ingestion.restoreSatelliteDependency(); + parts.panel.renderMissionPanel(); + }, + + async destroy(viewer) { + layerState._sourceController.abort(); + releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) + layerState._enabled = false; + layerState._lifecycleToken++; + layerState._updateDirty = false; + await parts.ingestion.restoreSatelliteDependency(); + parts.panel.clearMissionRosterHover(); + parts.replay.stopMissionReplay(); + parts.selection.stopMissionZoomAnchor(); + parts.ingestion.clearPostTleRetry(); + if (layerState._clickHandler) layerState._clickHandler.destroy(); + layerState._clickHandler = null; + if (layerState._moveHandler) layerState._moveHandler.destroy(); + layerState._moveHandler = null; + if (layerState._declutterHandler) layerState._declutterHandler(); + layerState._declutterHandler = null; + parts.launchPad.destroyLaunchPadZonePrimitive(); + parts.orbitRendering.removeMissionOrbitPrimitives(); + parts.overlays.clearMissionOverlaySources(); + parts.overlays.destroyReplayVehicleOverlay(); + layerState._launches = []; + layerState._missionOverlayRecords.clear(); + layerState._animationStarts.clear(); + layerState._satelliteTelemetry.clear(); + layerState._replayTracks.clear(); + layerState._missionPanel?.remove(); + layerState._missionPanel = null; + if (layerState._missionRoster) { + layerState._missionRoster.onclick = null; + } + layerState._missionRosterPreviewOwnership = null; + layerState._missionRoster = null; + layerState._viewer = null; + if (layerState._dataSource) + viewer.dataSources.remove(layerState._dataSource, true); + layerState._dataSource = null; + layerState._count = 0; + layerState._orbitMatches = 0; + layerState._lastUpdate = null; + layerState._activeTleText = null; + layerState._activeTlePromise = null; + layerState._activeTlePromiseToken = 0; + layerState._renderedTleText = null; + layerState._postTleRetryCount = 0; + layerState._satelliteStateBeforeMission = null; + layerState._satelliteActivationPromise = null; + layerState._dataManager = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/launches/model.js b/src/layers/launches/model.js new file mode 100644 index 000000000..641b8d157 --- /dev/null +++ b/src/layers/launches/model.js @@ -0,0 +1,241 @@ +import * as Cesium from 'cesium'; +import { WINDOW_DAYS } from './policy.js'; + +export function createModel({ state: layerState, services, parts, source }) { + function finiteCoordinate(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; + } + + function normalizePayloadFlights(launch) { + const flights = + launch.rocket?.payloads || + launch.payloads || + launch.mission?.payloads || + []; + if (!Array.isArray(flights)) return []; + return flights.map((flight, index) => { + const payload = flight.payload || flight; + return { + id: String(flight.id || payload.id || `payload-${index}`), + name: payload.name || flight.name || 'Unnamed payload', + type: payload.type?.name || flight.type?.name || null, + manufacturer: payload.manufacturer?.name || null, + operator: payload.operator?.name || null, + destination: flight.destination || payload.destination || null, + amount: Number.isFinite(Number(flight.amount)) + ? Number(flight.amount) + : 1, + massKg: + (typeof payload.mass === 'number' || + (typeof payload.mass === 'string' && payload.mass.trim() !== '')) && + Number.isFinite(Number(payload.mass)) && + Number(payload.mass) >= 0 + ? Number(payload.mass) + : null, + }; + }); + } + + function normalizeLanding(stage, fallbackName, category, index) { + const landing = stage?.landing; + const location = landing?.landing_location || {}; + const launcher = stage?.launcher || stage?.spacecraft || {}; + const serial = launcher.serial_number || stage?.serial_number || null; + const stageType = stage?.type?.name || stage?.type || category; + const attempted = landing?.attempt === true; + const success = landing?.success; + const recoveryType = landing?.type?.name || null; + const launcherStatus = launcher.status?.name || null; + const status = + success === true + ? 'RECOVERED' + : success === false + ? 'LOST' + : attempted + ? 'RECOVERY ATTEMPT' + : recoveryType + ? recoveryType.toUpperCase() + : launcherStatus + ? launcherStatus.toUpperCase() + : 'NO RECOVERY DATA'; + return { + id: String(stage?.id || landing?.id || `${category}-${index}`), + category, + name: [stageType, serial].filter(Boolean).join(' · ') || fallbackName, + serial, + reused: stage?.reused === true, + flightNumber: finiteCoordinate(stage?.launcher_flight_number), + status, + attempted, + success: success === true ? true : success === false ? false : null, + recoveryType, + destination: + location.name || landing?.destination || landing?.type?.name || null, + description: landing?.description || null, + downrangeKm: finiteCoordinate(landing?.downrange_distance), + lat: finiteCoordinate(location.latitude ?? landing?.latitude), + lon: finiteCoordinate(location.longitude ?? landing?.longitude), + }; + } + + function normalizeRecoveryStages(launch, payloads) { + const rocket = launch.rocket || {}; + const launcherStages = Array.isArray(rocket.launcher_stage) + ? rocket.launcher_stage + : []; + const spacecraftStages = Array.isArray(rocket.spacecraft_stage) + ? rocket.spacecraft_stage + : []; + const stages = [ + ...launcherStages.map((stage, index) => + normalizeLanding( + stage, + `Launcher stage ${index + 1}`, + 'LAUNCHER', + index, + ), + ), + ...spacecraftStages.map((stage, index) => + normalizeLanding( + stage, + `Spacecraft stage ${index + 1}`, + 'SPACECRAFT', + index, + ), + ), + ]; + const payloadFlights = rocket.payloads || launch.payloads || []; + if (Array.isArray(payloadFlights)) { + payloadFlights.forEach((flight, index) => { + if (!flight?.landing) return; + stages.push( + normalizeLanding( + { + ...flight, + type: payloads[index]?.type || 'Payload', + serial_number: payloads[index]?.name, + }, + payloads[index]?.name || `Payload ${index + 1}`, + 'PAYLOAD', + index, + ), + ); + }); + } + return stages; + } + + /** + * Select a stable marker color from the mission operator and payload name. + * Labels stay cyan so the layer remains visually coherent. + * @param {object} launch Normalized launch record. + * @returns {Cesium.Color} + */ + + function missionMarkerColor(launch) { + const identity = + `${launch.provider || ''} ${launch.name || ''} ${launch.missionName || ''}`.toLowerCase(); + if (/nasa|national aeronautics/.test(identity)) + return Cesium.Color.fromCssColorString('#ff9f43'); + if (/starlink|spacex|space exploration/.test(identity)) + return Cesium.Color.fromCssColorString('#4cc9f0'); + if (/rocket lab/.test(identity)) + return Cesium.Color.fromCssColorString('#7bed9f'); + if (/isro|indian space/.test(identity)) + return Cesium.Color.fromCssColorString('#ff66c4'); + if (/cnsa|china national|long march/.test(identity)) + return Cesium.Color.fromCssColorString('#ffd166'); + if (/blue origin/.test(identity)) + return Cesium.Color.fromCssColorString('#a78bfa'); + if (/ula|united launch alliance/.test(identity)) + return Cesium.Color.fromCssColorString('#f97316'); + if (/arianespace|esa|european space/.test(identity)) + return Cesium.Color.fromCssColorString('#60a5fa'); + if (launch.provider) return Cesium.Color.fromCssColorString('#c084fc'); + return Cesium.Color.fromCssColorString('#22e6e6'); + } + + /** + * Normalize a Launch Library 2 response into records suitable for the layer. + * Trajectory points are retained only when the upstream explicitly supplies + * them; orbital tracks must not be reconstructed from launch metadata. + * @param {object} payload Launch Library 2-compatible response. + * @param {Date} [now] Reference time used for the rolling window. + * @returns {Array} + */ + + function normalizeRocketLaunches(payload, now = new Date()) { + const launches = Array.isArray(payload) ? payload : payload?.results; + if (!Array.isArray(launches)) return []; + const cutoff = now.getTime() - WINDOW_DAYS * 86400000; + return launches + .map((launch) => { + const launchTime = + launch.net || launch.window_start || launch.pad?.location?.name; + const date = Date.parse(launchTime); + const pad = launch.pad || {}; + const location = pad.location || {}; + const coordinates = location.coordinates || ''; + const [coordinateLon, coordinateLat] = String(coordinates) + .split(',') + .map(Number); + const lat = Number.isFinite(Number(pad.latitude)) + ? Number(pad.latitude) + : coordinateLat; + const lon = Number.isFinite(Number(pad.longitude)) + ? Number(pad.longitude) + : coordinateLon; + const payloads = normalizePayloadFlights(launch); + return { + id: String( + launch.id || launch.slug || launch.name || `launch-${date}`, + ), + name: launch.name || 'Unnamed launch', + status: launch.status?.name || 'Unknown', + launchTime: Number.isFinite(date) + ? new Date(date).toISOString() + : null, + launchSite: pad.name || location.name || 'Unknown launch site', + lat: Number.isFinite(lat) ? lat : null, + lon: Number.isFinite(lon) ? lon : null, + provider: launch.launch_service_provider?.name || null, + mission: launch.mission?.description || null, + missionName: launch.mission?.name || null, + satelliteQuery: launch.mission?.name || launch.name || null, + payloads, + recoveryStages: normalizeRecoveryStages(launch, payloads), + trajectory: Array.isArray(launch.trajectory) ? launch.trajectory : [], + timeline: Array.isArray(launch.timeline) + ? launch.timeline.map((event) => ({ + name: + event.type?.abbrev || + event.type?.name || + event.name || + 'Mission event', + relativeTime: event.relative_time || event.relativeTime || null, + offsetSeconds: parts.policyHelpers.parseMissionDurationSeconds( + event.relative_time || event.relativeTime, + ), + })) + : [], + orbit: launch.mission?.orbit || launch.orbit || null, + source: 'Launch Library 2', + inWindow: + Number.isFinite(date) && date >= cutoff && date <= now.getTime(), + }; + }) + .filter( + (launch) => + launch.inWindow && launch.lat !== null && launch.lon !== null, + ); + } + return { + finiteCoordinate, + normalizePayloadFlights, + normalizeLanding, + normalizeRecoveryStages, + missionMarkerColor, + normalizeRocketLaunches, + }; +} diff --git a/src/layers/launches/orbitRendering.js b/src/layers/launches/orbitRendering.js new file mode 100644 index 000000000..d907904eb --- /dev/null +++ b/src/layers/launches/orbitRendering.js @@ -0,0 +1,183 @@ +import * as Cesium from 'cesium'; +import { + MISSION_ORBIT_PATTERN_GROUPS, + MISSION_ORBIT_DASHES_PER_GROUP, +} from './policy.js'; + +export function createOrbitRendering({ + state: layerState, + services, + parts, + source, +}) { + const { orbitFrameModelMatrix } = services.satellites; + + /** + * Register the selected-orbit tactical material once. Each group begins with + * one compact round dot followed by one hundred short dashes. + */ + + function ensureMissionOrbitPatternRegistered() { + if (layerState._missionOrbitPatternRegistered) return; + new Cesium.Material({ + fabric: { + type: 'GevMissionOrbitTactical', + uniforms: { + color: Cesium.Color.CYAN, + groupCount: MISSION_ORBIT_PATTERN_GROUPS, + dashCount: MISSION_ORBIT_DASHES_PER_GROUP, + }, + source: ` + czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material material = czm_getDefaultMaterial(materialInput); + float groupPosition = fract(materialInput.st.s * groupCount); + float markPosition = groupPosition * (dashCount + 1.0); + float markIndex = floor(markPosition); + float localPosition = fract(markPosition); + float centerDistance = abs(localPosition - 0.5); + float edge = max(fwidth(localPosition) * 1.35, 0.012); + float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance); + float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5)); + float dash = dashAlong * dashAcross; + float dotAlong = (localPosition - 0.5) / 0.32; + float dotAcross = (materialInput.st.t - 0.5) / 0.5; + float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross))); + float isDot = 1.0 - step(0.5, markIndex); + float visible = mix(dash, dot, isDot); + material.diffuse = color.rgb; + material.emission = color.rgb * mix(0.07, 0.65, isDot); + material.alpha = color.a * visible * mix(0.58, 1.0, isDot); + return material; + }`, + }, + }); + layerState._missionOrbitPatternRegistered = true; + } + + function createMissionOrbitPatternMaterial(color) { + ensureMissionOrbitPatternRegistered(); + return Cesium.Material.fromType('GevMissionOrbitTactical', { + color, + groupCount: MISSION_ORBIT_PATTERN_GROUPS, + dashCount: MISSION_ORBIT_DASHES_PER_GROUP, + }); + } + + function missionOrbitPrimitiveVisible(launchId) { + return Boolean( + layerState._enabled && + layerState._dataSource?.show && + (!layerState._selectedLaunchId || + layerState._selectedLaunchId === launchId), + ); + } + + function syncMissionOrbitPrimitiveVisibility() { + for (const [launchId, path] of layerState._missionOrbitPrimitives) { + if (path.primitive) + path.primitive.show = missionOrbitPrimitiveVisible(launchId); + } + } + + function removeMissionOrbitPrimitives() { + for (const path of layerState._missionOrbitPrimitives.values()) { + if (path.primitive && layerState._viewer?.scene?.primitives) { + layerState._viewer.scene.primitives.remove(path.primitive); + } + } + layerState._missionOrbitPrimitives.clear(); + } + + function updateMissionOrbitPrimitiveFrames(nowDate) { + for (const [launchId, path] of layerState._missionOrbitPrimitives) { + if (!path.primitive || !missionOrbitPrimitiveVisible(launchId)) continue; + orbitFrameModelMatrix( + path.gmstAtBake, + nowDate, + path.primitive.modelMatrix, + ); + if (path.labelBakePosition && path.labelPosition) { + Cesium.Matrix4.multiplyByPoint( + path.primitive.modelMatrix, + path.labelBakePosition, + path.labelPosition, + ); + } + } + } + + function addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack) { + if ( + !layerState._viewer || + !satelliteTrack || + !Number.isFinite(satelliteTrack.gmstAtBake) + ) + return false; + const collection = new Cesium.PolylineCollection(); + collection.add({ + positions: orbitPath, + width: 3, + material: createMissionOrbitPatternMaterial( + Cesium.Color.fromCssColorString('#22e6e6').withAlpha(0.95), + ), + }); + collection.show = missionOrbitPrimitiveVisible(launch.id); + orbitFrameModelMatrix( + satelliteTrack.gmstAtBake, + new Date(), + collection.modelMatrix, + ); + layerState._viewer.scene.primitives.add(collection); + layerState._missionOrbitPrimitives.set(launch.id, { + primitive: collection, + gmstAtBake: satelliteTrack.gmstAtBake, + }); + return true; + } + + function MissionOrbitPatternMaterialProperty(color) { + ensureMissionOrbitPatternRegistered(); + this._color = color; + this._definitionChanged = new Cesium.Event(); + } + Object.defineProperties(MissionOrbitPatternMaterialProperty.prototype, { + isConstant: { + get() { + return true; + }, + }, + definitionChanged: { + get() { + return this._definitionChanged; + }, + }, + }); + MissionOrbitPatternMaterialProperty.prototype.getType = function getType() { + return 'GevMissionOrbitTactical'; + }; + MissionOrbitPatternMaterialProperty.prototype.getValue = function getValue( + time, + result, + ) { + if (!Cesium.defined(result)) result = {}; + result.color = this._color; + result.groupCount = MISSION_ORBIT_PATTERN_GROUPS; + result.dashCount = MISSION_ORBIT_DASHES_PER_GROUP; + return result; + }; + MissionOrbitPatternMaterialProperty.prototype.equals = function equals( + other, + ) { + return this === other; + }; + return { + ensureMissionOrbitPatternRegistered, + createMissionOrbitPatternMaterial, + missionOrbitPrimitiveVisible, + syncMissionOrbitPrimitiveVisibility, + removeMissionOrbitPrimitives, + updateMissionOrbitPrimitiveFrames, + addMissionOrbitPrimitive, + MissionOrbitPatternMaterialProperty, + }; +} diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js new file mode 100644 index 000000000..600b5c781 --- /dev/null +++ b/src/layers/launches/overlays.js @@ -0,0 +1,616 @@ +import * as Cesium from 'cesium'; +import { + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS, +} from './policy.js'; + +export function createOverlays({ state: layerState, services, parts, source }) { + /** + * Resolve whether a surface mission anchor is safely on the camera-facing + * side of Earth. The small positive limb margin prevents labels anchored just + * beyond the horizon from leaking through the hidden Cesium globe. + * @param {Cesium.Cartesian3} cameraPosition Camera world position. + * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. + * @param {number} [limbMargin] Additional normalized horizon clearance. + * @returns {boolean} Whether the marker belongs on the visible hemisphere. + */ + + function missionAnchorHorizonVisible( + cameraPosition, + markerPosition, + limbMargin = 0.012, + ) { + if (!cameraPosition || !markerPosition) return false; + const cameraMagnitude = Cesium.Cartesian3.magnitude(cameraPosition); + if ( + !Number.isFinite(cameraMagnitude) || + cameraMagnitude <= Cesium.Ellipsoid.WGS84.maximumRadius + ) { + return false; + } + const cameraDirection = Cesium.Cartesian3.normalize( + cameraPosition, + new Cesium.Cartesian3(), + ); + const markerDirection = Cesium.Cartesian3.normalize( + markerPosition, + new Cesium.Cartesian3(), + ); + const limbThreshold = + Cesium.Ellipsoid.WGS84.maximumRadius / cameraMagnitude; + return ( + Cesium.Cartesian3.dot(cameraDirection, markerDirection) > + limbThreshold + limbMargin + ); + } + + /** + * Resolve launch-anchor visibility for overview and selected-mission views. + * @param {Cesium.Cartesian3} cameraPosition Camera world position. + * @param {Cesium.Cartesian3} markerPosition Mission anchor world position. + * @param {string} markerId Mission represented by this anchor. + * @param {string|null} selectedLaunchId Explicitly selected mission. + * @returns {boolean} Whether the launch anchor should render. + */ + + function missionAnchorVisible( + cameraPosition, + markerPosition, + markerId, + selectedLaunchId = null, + ) { + if (selectedLaunchId && markerId !== selectedLaunchId) return false; + return missionAnchorHorizonVisible(cameraPosition, markerPosition); + } + + function shortMissionLabel(name, maxLength = 24) { + const text = String(name || 'Unnamed mission') + .replace(/\s+/g, ' ') + .trim() + .split(' | ')[0]; + const compact = text.split(' — ')[0].trim(); + return compact.length > maxLength + ? `${compact.slice(0, maxLength - 1).trimEnd()}…` + : compact; + } + + /** + * Reduce generic launch-complex names to their identifying pad or area suffix. + * @param {string|null} launchSite Launch Library pad name. + * @returns {string|null} Compact launch-site identifier. + */ + + function compactLaunchSiteName(launchSite) { + const text = String(launchSite || '') + .replace(/\s+/g, ' ') + .trim(); + if (!text || /^(unknown|unavailable|n\/a)$/i.test(text)) return null; + const genericPrefix = + /^(?:orbital\s+launch\s+pad|space\s+launch\s+complex|launch\s+(?:area|complex|pad|site))\s*[-·:]?\s*/i; + const compact = text.replace(genericPrefix, '').trim(); + return compact || null; + } + + /** + * Build the source-owned launch-site marker presentation. Overview markers + * compete in the bounded ambient-label domain; the selected mission moves to + * the protected selected lane and gains the former launch-site detail line. + * @param {object} launch Normalized Launch Library mission. + * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} position Existing display position. + * @param {boolean} [selected=false] Whether the mission owns the selected view. + * @returns {object} + */ + + function createRocketMissionMarkerOverlayEntry( + launch, + position, + selected = false, + ) { + const mission = shortMissionLabel(launch?.name, 26).toUpperCase(); + const siteName = compactLaunchSiteName(launch?.launchSite); + const launchTimeMs = Date.parse(launch?.launchTime); + const details = selected + ? [ + siteName + ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` + : 'LAUNCH SITE', + ] + : []; + return { + id: `launch:${launch?.id}`, + position, + variant: 'label', + title: mission, + details, + accent: '#22e6e6', + priority: selected + ? Number.MAX_SAFE_INTEGER + : Number.isFinite(launchTimeMs) + ? Math.floor(launchTimeMs / 1000) + : 0, + selected, + protected: selected, + paintLane: selected ? 'selected' : 'ambient-label', + collisionGroup: 'ambient-label', + interactive: false, + distanceScale: { + near: 1000, + nearValue: 1.08, + far: 20_000_000, + farValue: 0.78, + }, + gapPx: 12, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; + } + + /** + * Build one protected label belonging to the selected mission's trajectory, + * live payload position, or orbit. Copy is already source-formatted by the + * caller; newline semantics become explicit host detail rows. + * @param {object} input + * @param {string} input.id Stable mission-element identity. + * @param {Cesium.Cartesian3|function():Cesium.Cartesian3} input.position Existing cached position. + * @param {string} input.text Former native-label text, including newlines. + * @param {string} input.accent Source-owned label color. + * @param {number} [input.priority=0] Protected placement order. + * @param {number} [input.gapPx=8] Anchor-to-label gap. + * @returns {object} + */ + + function createRocketMissionElementOverlayEntry({ + id, + position, + text, + accent, + priority = 0, + gapPx = 8, + }) { + const [title, ...details] = String(text || '').split('\n'); + return { + id: String(id), + position, + variant: 'label', + title, + details, + accent, + priority, + selected: true, + protected: true, + paintLane: 'selected', + collisionGroup: 'ambient-label', + interactive: false, + gapPx, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; + } + + /** Keep the newest ambient mission markers with stable identity tie-breaking. */ + + function selectRocketMissionMarkerOverlayCohort( + entries, + limit = ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + ) { + const cap = Math.max( + 0, + Math.min( + ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + Math.floor(Number(limit) || 0), + ), + ); + if (!Array.isArray(entries) || cap === 0) return []; + return entries + .slice() + .sort( + (a, b) => + b.priority - a.priority || String(a.id).localeCompare(String(b.id)), + ) + .slice(0, cap); + } + + function missionHoverReticleImage() { + if (layerState._missionHoverReticleImage) + return layerState._missionHoverReticleImage; + const canvas = document.createElement('canvas'); + const size = 48; + const inset = 6; + const arm = 12; + canvas.width = size; + canvas.height = size; + const context = canvas.getContext('2d'); + context.strokeStyle = '#22e6e6'; + context.lineWidth = 2; + context.lineCap = 'square'; + context.shadowColor = 'rgba(34, 230, 230, .72)'; + context.shadowBlur = 5; + context.beginPath(); + context.moveTo(inset, inset + arm); + context.lineTo(inset, inset); + context.lineTo(inset + arm, inset); + context.moveTo(size - inset - arm, inset); + context.lineTo(size - inset, inset); + context.lineTo(size - inset, inset + arm); + context.moveTo(inset, size - inset - arm); + context.lineTo(inset, size - inset); + context.lineTo(inset + arm, size - inset); + context.moveTo(size - inset - arm, size - inset); + context.lineTo(size - inset, size - inset); + context.lineTo(size - inset, size - inset - arm); + context.stroke(); + layerState._missionHoverReticleImage = canvas; + return canvas; + } + + /** + * Resolve the one permitted screen-space replay overlay state. + * @param {object} input Overlay state. + * @param {boolean} input.replayActive Whether replay owns the camera. + * @param {boolean} input.ascending Whether the replay marker is on ascent. + * @param {boolean} input.countdownActive Whether the T-minus hold is active. + * @returns {'countdown'|'ascent'|'orbit'|null} + */ + + function replayOverlayMode({ + replayActive, + ascending, + countdownActive, + preCountdownActive = false, + }) { + if (!replayActive) return null; + if (preCountdownActive) return null; + if (countdownActive) return 'countdown'; + return ascending ? 'ascent' : 'orbit'; + } + + function createReplayVehicleOverlay() { + if (layerState._replayVehicleOverlay || typeof document === 'undefined') + return; + const host = document.getElementById('cesiumContainer') || document.body; + layerState._replayVehicleOverlay = document.createElement('div'); + layerState._replayVehicleOverlay.className = + 'mission-replay-vehicle-overlay'; + layerState._replayVehicleOverlay.hidden = true; + layerState._replayVehicleOverlay.setAttribute('aria-hidden', 'true'); + layerState._replayVehicleOverlay.innerHTML = ` +
+ + +
+ +
+ + +
`; + host.appendChild(layerState._replayVehicleOverlay); + } + + function hideReplayVehicleOverlay() { + if (!layerState._replayVehicleOverlay) return; + layerState._replayVehicleOverlay.hidden = true; + layerState._replayVehicleOverlay.classList.remove( + 'is-thrusting', + 'is-paused', + ); + } + + function destroyReplayVehicleOverlay() { + layerState._replayVehicleOverlay?.remove(); + layerState._replayVehicleOverlay = null; + layerState._replayVehicleOverlayText = ''; + } + + function clearMissionOverlaySources() { + layerState._selectedMissionOverlayTimeText = null; + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + false, + ); + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + } + + function syncMissionOverlayEntries() { + if (!layerState._enabled || !layerState._dataSource?.show) { + clearMissionOverlaySources(); + return; + } + const selectedRecord = layerState._selectedLaunchId + ? layerState._missionOverlayRecords.get(layerState._selectedLaunchId) + : null; + if (selectedRecord) { + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + false, + ); + const entries = selectedRecord.elementEntryFactories.map((createEntry) => + createEntry(), + ); + if (layerState._replayCameraLaunchId !== selectedRecord.launch.id) { + entries.unshift( + createRocketMissionMarkerOverlayEntry( + selectedRecord.launch, + selectedRecord.anchorPosition, + true, + ), + ); + } + layerState._missionOverlayHost.setEntries( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + entries, + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + true, + ); + layerState._selectedMissionOverlayTimeText = selectedRecord.liveEventTime + ? parts.policyHelpers.formatMissionEventTime( + selectedRecord.liveEventTime(), + ) + : null; + return; + } + + layerState._missionOverlayHost.clearSource( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, + false, + ); + layerState._selectedMissionOverlayTimeText = null; + const entries = selectRocketMissionMarkerOverlayCohort( + Array.from(layerState._missionOverlayRecords.values(), (record) => { + const entry = createRocketMissionMarkerOverlayEntry( + record.launch, + record.anchorPosition, + ); + if (record.launch.id === layerState._hoveredRosterLaunchId) { + entry.pinned = true; + entry.priority = Number.MAX_SAFE_INTEGER - 1; + } + return entry; + }), + ); + layerState._missionOverlayHost.setEntries( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + entries, + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS, + ); + layerState._missionOverlayHost.setVisible( + ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, + true, + ); + } + + function refreshSelectedMissionOverlayText() { + const selectedRecord = layerState._selectedLaunchId + ? layerState._missionOverlayRecords.get(layerState._selectedLaunchId) + : null; + if (!selectedRecord?.liveEventTime) return; + const nextText = parts.policyHelpers.formatMissionEventTime( + selectedRecord.liveEventTime(), + ); + if (nextText !== layerState._selectedMissionOverlayTimeText) + syncMissionOverlayEntries(); + } + + function updateReplayVehicleOverlay(occluder) { + if ( + !layerState._viewer || + !layerState._replayVehicleOverlay || + !layerState._selectedLaunchId + ) { + hideReplayVehicleOverlay(); + return null; + } + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + const track = layerState._replayTracks.get(layerState._selectedLaunchId); + if (!launch || !track) { + hideReplayVehicleOverlay(); + return null; + } + const replayActive = layerState._replayCameraLaunchId === launch.id; + const state = track.getReplayState(); + const mode = replayOverlayMode({ + replayActive, + ascending: state.ascending, + countdownActive: state.countdownActive, + preCountdownActive: state.preCountdownActive, + }); + if (!mode) { + track.lastOverlayWindowPosition = null; + track.lastOverlayMode = null; + hideReplayVehicleOverlay(); + return null; + } + const path = state.ascending ? track.ascentPath : track.animatedOrbitPath; + // Do not call scene.sampleHeight() from this post-render path. A selected + // mission can remain visible from globe range, and a periodic remote height + // probe forces Google Photorealistic 3D Tiles to reconsider its refinement + // set even though the camera is stationary. That presented as a one-second + // shrink/expand pulse across the globe. Replay follows its already-built, + // surface-safe Cartesian path and therefore needs no runtime pad sampling. + const position = parts.paths.samplePath(path, state.phaseProgress); + if (!position || !occluder.isPointVisible(position)) { + hideReplayVehicleOverlay(); + return null; + } + const windowPosition = Cesium.SceneTransforms.worldToWindowCoordinates( + layerState._viewer.scene, + position, + ); + if (!windowPosition) { + hideReplayVehicleOverlay(); + return null; + } + const canvas = layerState._viewer.scene.canvas; + if ( + windowPosition.x < -80 || + windowPosition.y < -100 || + windowPosition.x > canvas.clientWidth + 80 || + windowPosition.y > canvas.clientHeight + 100 + ) { + hideReplayVehicleOverlay(); + return null; + } + // The replay camera already targets this same frame-cached world position. + // Applying a second temporal filter here made the DOM vehicle trail the + // camera until the snap threshold was crossed, producing a repeating + // forward/back jump. Keep smoothing only for the non-tracked close-up pad + // marker, where the user can move the camera independently. + const renderedWindowPosition = + !replayActive && track.lastOverlayMode === mode + ? parts.replay.smoothReplayWindowPosition( + track.lastOverlayWindowPosition, + windowPosition, + ) + : windowPosition; + track.lastOverlayWindowPosition = renderedWindowPosition; + track.lastOverlayMode = mode; + layerState._replayVehicleOverlay.hidden = false; + layerState._replayVehicleOverlay.dataset.mode = mode; + layerState._replayVehicleOverlay.classList.toggle( + 'is-thrusting', + replayActive && state.ascending && !state.countdownActive, + ); + layerState._replayVehicleOverlay.classList.toggle( + 'is-paused', + replayActive && layerState._replayPaused, + ); + layerState._replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`; + let vehicleRotation = 0; + if (replayActive && !state.countdownActive) { + const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1)); + const forwardProgress = Math.min(1, state.phaseProgress + tangentStep); + const backwardProgress = Math.max(0, state.phaseProgress - tangentStep); + const useForward = forwardProgress > state.phaseProgress; + const tangentPosition = parts.paths.samplePath( + path, + useForward ? forwardProgress : backwardProgress, + ); + const tangentWindowPosition = tangentPosition + ? Cesium.SceneTransforms.worldToWindowCoordinates( + layerState._viewer.scene, + tangentPosition, + ) + : null; + if (tangentWindowPosition) { + const screenDelta = Math.hypot( + tangentWindowPosition.x - windowPosition.x, + tangentWindowPosition.y - windowPosition.y, + ); + // When the path points almost directly into/out of the camera, its + // screen projection has no trustworthy direction. Keep the last valid + // path-facing pose instead of snapping the rocket to a camera-facing + // upright orientation. + if (screenDelta >= 2) { + vehicleRotation = useForward + ? parts.replay.replayVehicleScreenRotation( + windowPosition, + tangentWindowPosition, + ) + : parts.replay.replayVehicleScreenRotation( + tangentWindowPosition, + windowPosition, + ); + track.lastVehicleRotation = vehicleRotation; + } else if (Number.isFinite(track.lastVehicleRotation)) { + vehicleRotation = track.lastVehicleRotation; + } + } + } + layerState._replayVehicleOverlay.style.setProperty( + '--mission-replay-rotation', + `${Cesium.Math.toDegrees(vehicleRotation)}deg`, + ); + + const mission = shortMissionLabel(launch.name, 22).toUpperCase(); + const siteName = compactLaunchSiteName(launch.launchSite); + const siteCallout = siteName + ? `LAUNCH SITE · ${shortMissionLabel(siteName, 20).toUpperCase()}` + : 'LAUNCH SITE'; + let title = mission; + let detail = siteCallout; + if (mode === 'countdown') { + title = `T−${String(state.countdownSeconds).padStart(2, '0')} · ${mission}`; + detail = `LAUNCH STANDBY\n${siteCallout}`; + } else if (mode === 'ascent') { + title = + state.elapsedSinceStart < 1 + ? `LIFTOFF · ${mission}` + : `${launch.trajectory.length > 1 ? 'ASCENT REPLAY' : 'ASCENT ESTIMATE'} · ${mission}`; + detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); + } else if (mode === 'recovery') { + title = `STAGE RE-ENTRY / RECOVERY · ${mission}`; + detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); + } else if (mode === 'orbit') { + title = `ORBIT REPLAY · ${mission}`; + detail = parts.policyHelpers.formatMissionEventTime(state.eventTime); + } + if (layerState._replayPaused) title = `PAUSED · ${title}`; + const nextText = `${title}\n${detail}`; + if (nextText !== layerState._replayVehicleOverlayText) { + layerState._replayVehicleOverlayText = nextText; + layerState._replayVehicleOverlay.querySelector( + '[data-replay-overlay-title]', + ).textContent = title; + layerState._replayVehicleOverlay.querySelector( + '[data-replay-overlay-detail]', + ).textContent = detail; + } + return mode; + } + return { + missionAnchorHorizonVisible, + missionAnchorVisible, + shortMissionLabel, + compactLaunchSiteName, + createRocketMissionMarkerOverlayEntry, + createRocketMissionElementOverlayEntry, + selectRocketMissionMarkerOverlayCohort, + missionHoverReticleImage, + replayOverlayMode, + createReplayVehicleOverlay, + hideReplayVehicleOverlay, + destroyReplayVehicleOverlay, + clearMissionOverlaySources, + syncMissionOverlayEntries, + refreshSelectedMissionOverlayText, + updateReplayVehicleOverlay, + }; +} diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js new file mode 100644 index 000000000..1555b5a66 --- /dev/null +++ b/src/layers/launches/panel.js @@ -0,0 +1,543 @@ +import * as Cesium from 'cesium'; +import { + MISSION_CLOSE_VIEW_RANGE_M, + MISSION_GLOBE_VIEW_RANGE_M, +} from './policy.js'; + +export function createPanel({ state: layerState, services, parts, source }) { + /** + * Preserve the user's globe scale for roster previews while avoiding an + * accidental surface-level fly-to when the list is opened from a close view. + * @param {number} cameraHeight Current camera height above the ellipsoid. + * @returns {number} Preview range in metres. + */ + + function missionHoverPreviewRange(cameraHeight) { + const height = Number(cameraHeight); + return Math.max( + MISSION_CLOSE_VIEW_RANGE_M, + Number.isFinite(height) ? height : MISSION_GLOBE_VIEW_RANGE_M, + ); + } + + /** + * Coordinate pointer and keyboard ownership of the temporary roster preview. + * The most recent input owns the preview until it leaves, then any remaining + * input resumes ownership. This lets keyboard focus take over from a pointer + * resting on another row without losing that pointer preview on blur. + * @param {{preview: (index: number) => void, clear: () => void}} handlers + * @returns {{pointerEnter: (index: number) => void, pointerLeave: () => void, + * focus: (index: number) => void, blur: (nextIndex?: number|null) => void, + * reset: () => void}} + */ + + function createMissionRosterPreviewOwnership({ preview, clear }) { + let pointerIndex = null; + let focusIndex = null; + let latestOwner = null; + let activeIndex = null; + + const sync = () => { + const nextIndex = + latestOwner === 'pointer' + ? (pointerIndex ?? focusIndex) + : (focusIndex ?? pointerIndex); + if (nextIndex === activeIndex) return; + activeIndex = nextIndex; + if (Number.isInteger(nextIndex)) preview(nextIndex); + else clear(); + }; + + return { + pointerEnter(index) { + pointerIndex = index; + latestOwner = 'pointer'; + sync(); + }, + pointerLeave() { + pointerIndex = null; + if (latestOwner === 'pointer') + latestOwner = focusIndex === null ? null : 'focus'; + sync(); + }, + focus(index) { + focusIndex = index; + latestOwner = 'focus'; + sync(); + }, + blur(nextIndex = null) { + focusIndex = Number.isInteger(nextIndex) ? nextIndex : null; + if (latestOwner === 'focus') + latestOwner = + focusIndex === null + ? pointerIndex === null + ? null + : 'pointer' + : 'focus'; + sync(); + }, + reset() { + pointerIndex = null; + focusIndex = null; + latestOwner = null; + activeIndex = null; + clear(); + }, + }; + } + + /** + * Bind native keyboard focus events for one rendered roster row. + * @param {Element} button Mission roster button. + * @param {number} index Source-array mission index. + * @param {{pointerEnter: (index: number) => void, pointerLeave: () => void, + * focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership + * @param {Element} roster Roster root used to reject focus targets outside the list. + */ + + function bindMissionRosterItemKeyboardPreview( + button, + index, + ownership, + roster, + ) { + button.addEventListener('mouseenter', () => ownership.pointerEnter(index)); + button.addEventListener('mouseleave', () => ownership.pointerLeave()); + button.addEventListener('focus', () => ownership.focus(index)); + button.addEventListener('blur', (event) => { + const nextButton = event.relatedTarget?.closest?.( + '[data-mission-roster-index]', + ); + const nextIndex = + nextButton && roster.contains(nextButton) + ? Number(nextButton.dataset.missionRosterIndex) + : null; + ownership.blur(nextIndex); + }); + } + + /** Capture the exact mission identity owned by keyboard focus before a refresh. */ + + function captureMissionRosterFocus( + list, + activeElement = globalThis.document?.activeElement, + ) { + const button = activeElement?.closest?.('[data-mission-roster-id]'); + if (!button || !list?.contains(button)) return null; + const launchId = button.dataset.missionRosterId; + return launchId ? { launchId } : null; + } + + /** Restore focus by mission identity, or continue after the list if it departed. */ + + function restoreMissionRosterFocus(list, snapshot, continuation) { + if (!snapshot?.launchId) return 'none'; + const button = Array.from( + list?.querySelectorAll?.('[data-mission-roster-id]') || [], + ).find( + (candidate) => candidate.dataset.missionRosterId === snapshot.launchId, + ); + if (button) { + button.focus({ preventScroll: true }); + return 'restored'; + } + if (continuation?.focus) { + continuation.focus({ preventScroll: true }); + return 'continued'; + } + return 'none'; + } + + /** Resolve a pending preview against the current refresh, never a stale object. */ + + function resolveMissionRosterPreviewLaunch(launches, launchId) { + return ( + (launches || []).find((candidate) => candidate.id === launchId) || null + ); + } + + function missionTableRows(items, columns, emptyText) { + if (!items.length) + return `${emptyText}`; + return items.map((item) => item).join(''); + } + + function setMissionPanelField(selector, value, title = '') { + const output = layerState._missionPanel?.querySelector(selector); + if (!output) return; + const row = output.closest('[data-mission-field]'); + const available = + value !== null && value !== undefined && String(value).trim() !== ''; + if (row) row.hidden = !available; + if (!available) { + output.textContent = ''; + output.removeAttribute('title'); + return; + } + output.textContent = value; + if (title) output.title = title; + else output.removeAttribute('title'); + } + + function renderMissionPanel() { + if (!layerState._missionPanel) return; + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + const index = launch ? layerState._launches.indexOf(launch) : -1; + layerState._missionPanel.hidden = !launch; + if (!launch) return; + layerState._missionPanel.querySelector('[data-mission-title]').textContent = + parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(); + setMissionPanelField('[data-mission-provider]', launch.provider); + setMissionPanelField('[data-mission-status]', launch.status); + setMissionPanelField( + '[data-mission-site]', + launch.launchSite && launch.launchSite !== 'Unknown launch site' + ? launch.launchSite + : null, + ); + setMissionPanelField('[data-mission-time]', launch.launchTime); + const pathPresentation = parts.policyHelpers.missionPathPresentation( + launch, + layerState._replayTracks.has(launch.id), + ); + setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit); + layerState._missionPanel.querySelector( + '[data-mission-ascent-source]', + ).textContent = pathPresentation.ascent; + const payloadRows = launch.payloads.length + ? launch.payloads.slice(0, 5).map((payload) => { + const detail = [ + payload.manufacturer, + payload.operator && payload.operator !== payload.manufacturer + ? payload.operator + : null, + Number.isFinite(payload.massKg) + ? `${payload.massKg.toLocaleString()} KG` + : null, + ] + .filter(Boolean) + .join(' · '); + return `${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `${escapeMissionText(detail)}` : ''}${escapeMissionText(payload.type || 'UNSPECIFIED')}${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}`; + }) + : []; + if (launch.payloads.length > 5) { + payloadRows.push( + `+${launch.payloads.length - 5} additional payload records`, + ); + } + layerState._missionPanel.querySelector( + '[data-mission-payloads]', + ).innerHTML = missionTableRows(payloadRows, 3, 'PAYLOAD DATA UNAVAILABLE'); + const stageRows = launch.recoveryStages.map((stage) => { + const endpoint = stage.endpoint; + const destination = + stage.destination || + (endpoint?.accuracy === 'PAD / RTLS' + ? launch.launchSite + : 'UNAVAILABLE'); + const position = endpoint + ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}` + : stage.downrangeKm > 0 + ? `${stage.downrangeKm.toLocaleString()} KM DOWNRANGE` + : 'POSITION UNAVAILABLE'; + const stageDetail = [ + Number.isFinite(stage.flightNumber) + ? `FLIGHT ${stage.flightNumber}` + : null, + stage.reused ? 'REUSED' : null, + stage.recoveryType, + ] + .filter(Boolean) + .join(' · '); + return `${escapeMissionText(stage.name)}${stageDetail ? `${escapeMissionText(stageDetail)}` : ''}${escapeMissionText(stage.status)}${escapeMissionText(destination)}${escapeMissionText(position)}`; + }); + layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML = + missionTableRows(stageRows, 3, 'NO STAGE RE-ENTRY / RECOVERY DATA'); + const stageSection = layerState._missionPanel.querySelector( + '[data-mission-stages-section]', + ); + if (stageSection) stageSection.hidden = stageRows.length === 0; + updateMissionTelemetry(true); + layerState._missionPanel.querySelector('[data-mission-index]').textContent = + `${index + 1} / ${layerState._launches.length}`; + layerState._missionPanel.querySelector('[data-mission-prev]').disabled = + index <= 0; + layerState._missionPanel.querySelector('[data-mission-next]').disabled = + index < 0 || index >= layerState._launches.length - 1; + parts.replay.syncReplayButton(); + const panelScroller = layerState._missionPanel.closest( + '.global-context-panel-inner', + ); + if (panelScroller) panelScroller.scrollTop = 0; + } + + function renderMissionRoster() { + if (!layerState._missionRoster) return; + const list = layerState._missionRoster.querySelector( + '[data-mission-roster-list]', + ); + const count = layerState._missionRoster.querySelector( + '[data-mission-roster-count]', + ); + if (count) count.textContent = `${layerState._launches.length} / 30D`; + if (!list) return; + const focusSnapshot = captureMissionRosterFocus(list); + layerState._missionRosterPreviewOwnership?.reset(); + const entries = parts.policyHelpers.missionRosterEntries( + layerState._launches, + ); + if (!entries.length) { + list.innerHTML = + '
NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW
'; + restoreMissionRosterFocus( + list, + focusSnapshot, + layerState._missionRoster.querySelector( + '[data-mission-roster-focus-continuation]', + ), + ); + return; + } + list.innerHTML = entries + .map(({ launch, index }) => { + const color = parts.model.missionMarkerColor(launch).toCssColorString(); + const date = launch.launchTime?.slice(0, 10) || 'DATE UNAVAILABLE'; + const provider = launch.provider || 'UNSPECIFIED OPERATOR'; + const label = parts.overlays + .shortMissionLabel(launch.name, 27) + .toUpperCase(); + return ``; + }) + .join(''); + list.querySelectorAll('[data-mission-roster-index]').forEach((button) => { + const index = Number(button.dataset.missionRosterIndex); + bindMissionRosterItemKeyboardPreview( + button, + index, + layerState._missionRosterPreviewOwnership, + layerState._missionRoster, + ); + }); + restoreMissionRosterFocus( + list, + focusSnapshot, + layerState._missionRoster.querySelector( + '[data-mission-roster-focus-continuation]', + ), + ); + } + + function escapeMissionText(value) { + return String(value ?? '').replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character], + ); + } + + function updateMissionTelemetry(force = false) { + if ( + !layerState._missionPanel || + !layerState._selectedLaunchId || + !layerState._dataSource + ) + return; + const now = performance.now(); + if (!force && now - layerState._lastPanelTelemetryMs < 250) return; + layerState._lastPanelTelemetryMs = now; + const satellite = layerState._dataSource.entities.getById( + `rocket-satellite:${layerState._selectedLaunchId}`, + ); + const position = satellite?.position?.getValue( + Cesium.JulianDate.now(layerState._declutterTime), + ); + const altitudeM = position + ? Cesium.Cartographic.fromCartesian(position)?.height + : null; + setMissionPanelField( + '[data-mission-distance]', + Number.isFinite(altitudeM) + ? `${Math.max(0, altitudeM / 1000).toLocaleString(undefined, { maximumFractionDigits: 0 })} KM` + : null, + ); + const speedMps = layerState._satelliteTelemetry.get( + layerState._selectedLaunchId, + )?.speedMps; + setMissionPanelField( + '[data-mission-speed]', + Number.isFinite(speedMps) + ? `${(speedMps / 1000).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} KM/S` + : null, + Number.isFinite(speedMps) + ? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h` + : '', + ); + } + + function selectMissionAt(index) { + const launch = layerState._launches[index]; + if (!launch) return; + parts.selection.setSelectedMission(launch.id, true); + parts.selection.focusMission(launch); + } + + function clearMissionRosterPreviewState() { + if (layerState._missionRosterHoverTimer) + clearTimeout(layerState._missionRosterHoverTimer); + layerState._missionRosterHoverTimer = null; + const changed = layerState._hoveredRosterLaunchId !== null; + layerState._hoveredRosterLaunchId = null; + if (changed) parts.overlays.syncMissionOverlayEntries(); + } + + function clearMissionRosterHover() { + if (layerState._missionRosterPreviewOwnership) + layerState._missionRosterPreviewOwnership.reset(); + else clearMissionRosterPreviewState(); + } + + function scheduleMissionRosterPreview(index) { + const launch = layerState._launches[index]; + if (!launch || layerState._selectedLaunchId) return; + if (layerState._missionRosterHoverTimer) + clearTimeout(layerState._missionRosterHoverTimer); + layerState._hoveredRosterLaunchId = launch.id; + parts.overlays.syncMissionOverlayEntries(); + layerState._missionRosterHoverTimer = setTimeout(() => { + layerState._missionRosterHoverTimer = null; + if (layerState._hoveredRosterLaunchId !== launch.id) return; + const currentLaunch = resolveMissionRosterPreviewLaunch( + layerState._launches, + launch.id, + ); + if (currentLaunch) + parts.policyHelpers.previewMissionFromRoster(currentLaunch); + }, 140); + } + + function createMissionPanel() { + if (layerState._missionPanel || typeof document === 'undefined') return; + const host = + document.getElementById('space-mission-panel-host') || + document.getElementById('right-context-rail'); + if (!host) return; + layerState._missionRoster = document.getElementById('space-mission-roster'); + if (layerState._missionRoster) { + layerState._missionRosterPreviewOwnership = + createMissionRosterPreviewOwnership({ + preview: scheduleMissionRosterPreview, + clear: clearMissionRosterPreviewState, + }); + layerState._missionRoster.onclick = (event) => { + const button = + event.target instanceof Element + ? event.target.closest('[data-mission-roster-index]') + : null; + if (!button) return; + selectMissionAt(Number(button.dataset.missionRosterIndex)); + }; + } + layerState._missionPanel = document.createElement('aside'); + layerState._missionPanel.id = 'space-mission-panel'; + layerState._missionPanel.className = 'context-space-mission-detail'; + layerState._missionPanel.setAttribute( + 'aria-label', + 'Selected Space Mission', + ); + layerState._missionPanel.innerHTML = `
SELECTED SPACE MISSION
MISSIONSTATUS · LAUNCH SITE · LAUNCH TIME · ORBIT · ASCENT PATH · CURRENT DISTANCE FROM EARTH · SATELLITE SPEED ·

PAYLOAD

NAMETYPEDESTINATION

STAGE / RE-ENTRY / RECOVERY

STAGESTATUSFINAL POSITION
1×
—
`; + layerState._missionPanel + .querySelector('.mission-action-row') + .insertAdjacentHTML( + 'beforeend', + ``, + ); + host.appendChild(layerState._missionPanel); + layerState._missionPanel + .querySelector('[data-mission-prev]') + .addEventListener('click', () => + selectMissionAt( + layerState._launches.findIndex( + (item) => item.id === layerState._selectedLaunchId, + ) - 1, + ), + ); + layerState._missionPanel + .querySelector('[data-mission-next]') + .addEventListener('click', () => + selectMissionAt( + layerState._launches.findIndex( + (item) => item.id === layerState._selectedLaunchId, + ) + 1, + ), + ); + layerState._missionPanel + .querySelector('[data-mission-close]') + .addEventListener('click', () => + parts.selection.setSelectedMission(null), + ); + layerState._missionPanel + .querySelector('[data-mission-show-all]') + .addEventListener('click', () => + parts.selection.setSelectedMission(null), + ); + layerState._missionPanel + .querySelector('[data-mission-focus]') + .addEventListener('click', () => { + const launch = layerState._launches.find( + (item) => item.id === layerState._selectedLaunchId, + ); + if (launch) parts.selection.focusLaunchSite(launch); + }); + layerState._missionPanel + .querySelector('[data-mission-replay]') + .addEventListener('click', () => { + if (layerState._selectedLaunchId) + parts.replay.startMissionReplay(layerState._selectedLaunchId); + }); + layerState._missionPanel + .querySelector('[data-mission-replay-toggle]') + .addEventListener('click', () => { + if (layerState._replayPaused) parts.replay.resumeMissionReplay(); + else parts.replay.pauseMissionReplay(); + }); + layerState._missionPanel + .querySelector('[data-mission-replay-cancel]') + .addEventListener('click', parts.replay.stopMissionReplay); + layerState._missionPanel + .querySelector('[data-mission-replay-speed]') + .addEventListener('input', (event) => { + parts.replay.setReplaySpeed(event.currentTarget.value); + }); + parts.replay.syncReplaySpeedControl(); + } + return { + missionHoverPreviewRange, + createMissionRosterPreviewOwnership, + bindMissionRosterItemKeyboardPreview, + captureMissionRosterFocus, + restoreMissionRosterFocus, + resolveMissionRosterPreviewLaunch, + missionTableRows, + setMissionPanelField, + renderMissionPanel, + renderMissionRoster, + escapeMissionText, + updateMissionTelemetry, + selectMissionAt, + clearMissionRosterPreviewState, + clearMissionRosterHover, + scheduleMissionRosterPreview, + createMissionPanel, + }; +} diff --git a/src/layers/launches/paths.js b/src/layers/launches/paths.js new file mode 100644 index 000000000..72819ffb8 --- /dev/null +++ b/src/layers/launches/paths.js @@ -0,0 +1,588 @@ +import * as Cesium from 'cesium'; +import { + EARTH_ROTATION_RAD_PER_SEC, + PROJECTED_ASCENT_ROTATION_SEC, + STAGE_REENTRY_ALTITUDE_M, +} from './policy.js'; + +export function createPaths({ state: layerState, services, parts, source }) { + function orbitInsertionOffsetSeconds(launch) { + const events = (launch.timeline || []).filter( + (event) => + Number.isFinite(event.offsetSeconds) && event.offsetSeconds >= 0, + ); + if (!events.length) return null; + const deployment = events.filter((event) => + /deploy|payload separation|spacecraft separation|orbit insertion|injection/i.test( + event.name, + ), + ); + if (deployment.length) + return Math.max(...deployment.map((event) => event.offsetSeconds)); + const engineCutoff = events.filter((event) => + /seco|second engine cutoff/i.test(event.name), + ); + if (engineCutoff.length) + return Math.max(...engineCutoff.map((event) => event.offsetSeconds)); + return Math.max(...events.map((event) => event.offsetSeconds)); + } + + function estimatedOrbitPeriodSeconds(orbitPath) { + if (!orbitPath?.length) return 5400; + const meanRadius = + orbitPath.reduce( + (total, point) => total + Cesium.Cartesian3.magnitude(point), + 0, + ) / orbitPath.length; + return 2 * Math.PI * Math.sqrt(meanRadius ** 3 / 3.986004418e14); + } + + function approximateOrbitPath(launch) { + if ( + !launch.orbit?.name || + !Number.isFinite(launch.lat) || + !Number.isFinite(launch.lon) + ) + return null; + const orbitName = launch.orbit.name.toLowerCase(); + const altitude = + orbitName.includes('geostationary') || orbitName.includes('transfer') + ? 35786000 + : orbitName.includes('medium') + ? 20200000 + : 550000; + const radius = Cesium.Ellipsoid.WGS84.maximumRadius + altitude; + const longitude = Cesium.Math.toRadians(launch.lon); + const latitude = Cesium.Math.toRadians(launch.lat); + const up = new Cesium.Cartesian3( + Math.cos(latitude) * Math.cos(longitude), + Math.cos(latitude) * Math.sin(longitude), + Math.sin(latitude), + ); + const east = new Cesium.Cartesian3( + -Math.sin(longitude), + Math.cos(longitude), + 0, + ); + const north = new Cesium.Cartesian3( + -Math.sin(latitude) * Math.cos(longitude), + -Math.sin(latitude) * Math.sin(longitude), + Math.cos(latitude), + ); + const isPolar = orbitName.includes('polar') || orbitName.includes('sun'); + const isWesternNorthAmerica = + launch.lat > 20 && + launch.lat < 60 && + launch.lon > -140 && + launch.lon < -105; + const launchAzimuthDeg = isPolar + ? launch.lat >= 0 + ? 180 + : 0 + : isWesternNorthAmerica + ? 190 + : 90; + const launchAzimuth = Cesium.Math.toRadians(launchAzimuthDeg); + const forward = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + north, + Math.cos(launchAzimuth), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + east, + Math.sin(launchAzimuth), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + // A reconstructed orbit is an estimate, not a claim that the vehicle was + // inserted directly above the pad. Offset the orbital plane downrange by a + // small launch-to-insertion arc so a top-down view does not draw the entire + // orbit on top of the launch site. The ascent still joins this ring at its + // propagated insertion reference. + const insertionArc = Cesium.Math.toRadians(isPolar ? 8 : 12); + const orbitAnchor = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar( + up, + Math.cos(insertionArc), + new Cesium.Cartesian3(), + ), + Cesium.Cartesian3.multiplyByScalar( + forward, + Math.sin(insertionArc), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const planeNormal = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(orbitAnchor, forward, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const crossTrack = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross( + planeNormal, + orbitAnchor, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + return Array.from({ length: 97 }, (_, index) => { + const angle = (index / 96) * Math.PI * 2; + return new Cesium.Cartesian3( + radius * + (Math.cos(angle) * orbitAnchor.x + Math.sin(angle) * crossTrack.x), + radius * + (Math.cos(angle) * orbitAnchor.y + Math.sin(angle) * crossTrack.y), + radius * + (Math.cos(angle) * orbitAnchor.z + Math.sin(angle) * crossTrack.z), + ); + }); + } + + function samplePath(path, progress, result) { + if (!path?.length) return undefined; + // Degenerate returns clone into `result` when provided — handing back a + // path vertex would let an in-place caller mutate the path geometry. + if (path.length === 1) + return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; + let distances = layerState._pathDistanceCache.get(path); + if (!distances) { + distances = new Float64Array(path.length); + for (let index = 1; index < path.length; index++) { + distances[index] = + distances[index - 1] + + Cesium.Cartesian3.distance(path[index - 1], path[index]); + } + layerState._pathDistanceCache.set(path, distances); + } + const totalDistance = distances.at(-1); + if (!(totalDistance > 0)) + return result ? Cesium.Cartesian3.clone(path[0], result) : path[0]; + const targetDistance = Cesium.Math.clamp(progress, 0, 1) * totalDistance; + let low = 1; + let high = distances.length - 1; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (distances[middle] < targetDistance) low = middle + 1; + else high = middle; + } + const index = Math.max(0, low - 1); + const segmentDistance = distances[index + 1] - distances[index]; + const segmentProgress = + segmentDistance > 0 + ? (targetDistance - distances[index]) / segmentDistance + : 0; + return Cesium.Cartesian3.lerp( + path[index], + path[index + 1], + segmentProgress, + result || new Cesium.Cartesian3(), + ); + } + + /** + * Resolve a continuously advancing orbit fraction from a wall-clock epoch. + * Fractional seconds prevent the selected satellite marker from jumping at + * each second boundary and forcing a one-frame photoreal globe LOD pulse. + * @param {number} nowMs Wall-clock epoch in milliseconds. + * @param {number} periodSec Orbital period in seconds. + * @returns {number} Normalized progress in the range [0, 1). + */ + + function orbitProgressAtTime(nowMs, periodSec) { + const period = Math.max(1, Number(periodSec) || 1); + return Cesium.Math.mod((Number(nowMs) || 0) / 1000, period) / period; + } + + /** + * Construct one continuous estimated climb from the pad to orbit insertion. + * Horizontal movement begins much more slowly than altitude gain, preserving + * the near-vertical launch appearance without a hard corner at 120 km. + * @param {Cesium.Cartesian3} launchPosition Launch-pad position. + * @param {Cesium.Cartesian3} insertionPosition Orbit insertion position. + * @param {number} [samples] Number of curve intervals. + * @returns {Cesium.Cartesian3[]} + */ + + function reconstructedAscentPath( + launchPosition, + insertionPosition, + samples = 512, + ) { + const ellipsoid = Cesium.Ellipsoid.WGS84; + const origin = ellipsoid.cartesianToCartographic(launchPosition); + const insertion = ellipsoid.cartesianToCartographic(insertionPosition); + if (!origin || !insertion) return [launchPosition, insertionPosition]; + const geodesic = new Cesium.EllipsoidGeodesic( + new Cesium.Cartographic(origin.longitude, origin.latitude), + new Cesium.Cartographic(insertion.longitude, insertion.latitude), + ellipsoid, + ); + return Array.from({ length: samples + 1 }, (_, index) => { + const progress = index / samples; + if (index === 0) return launchPosition; + if (index === samples) return insertionPosition; + const horizontalProgress = progress ** 4; + const cartographic = geodesic.interpolateUsingFraction( + horizontalProgress, + new Cesium.Cartographic(), + ); + // Approximate the inertial eastward lead accumulated during a ten-minute + // ascent. The p³ envelope keeps liftoff nearly vertical, peaks during the + // upper climb, and returns to the fixed insertion endpoint. + const rotationalLead = + EARTH_ROTATION_RAD_PER_SEC * + PROJECTED_ASCENT_ROTATION_SEC * + Math.sin(Math.PI * progress ** 3); + cartographic.longitude = Cesium.Math.negativePiToPi( + cartographic.longitude + rotationalLead, + ); + cartographic.height = Cesium.Math.lerp( + Math.max(0, origin.height), + Math.max(0, insertion.height), + Math.sin(progress * Cesium.Math.PI_OVER_TWO), + ); + return ellipsoid.cartographicToCartesian(cartographic); + }); + } + + function nearestOrbitIndex(orbitPath, referencePosition) { + if (!orbitPath?.length || !referencePosition) return 0; + const referenceDirection = Cesium.Cartesian3.normalize( + referencePosition, + new Cesium.Cartesian3(), + ); + let bestIndex = 0; + let bestDot = -Number.MAX_VALUE; + orbitPath.forEach((candidate, index) => { + const direction = Cesium.Cartesian3.normalize( + candidate, + new Cesium.Cartesian3(), + ); + const dot = Cesium.Cartesian3.dot(referenceDirection, direction); + if (dot > bestDot) { + bestDot = dot; + bestIndex = index; + } + }); + return bestIndex; + } + + function orbitPathFromInsertion(orbitPath, insertionIndex) { + if (!orbitPath?.length) return []; + const first = orbitPath[0]; + const last = orbitPath.at(-1); + const isClosed = + orbitPath.length > 2 && Cesium.Cartesian3.distance(first, last) < 1000; + const core = isClosed ? orbitPath.slice(0, -1) : orbitPath.slice(); + if (!core.length) return orbitPath.slice(); + const index = Cesium.Math.mod(insertionIndex, core.length); + const rotated = [...core.slice(index), ...core.slice(0, index)]; + rotated.push(rotated[0]); + return rotated; + } + + function surfaceSafeSegment(startPosition, endPosition) { + const ellipsoid = Cesium.Ellipsoid.WGS84; + const start = ellipsoid.cartesianToCartographic(startPosition); + const end = ellipsoid.cartesianToCartographic(endPosition); + if (!start || !end) return [startPosition, endPosition]; + const geodesic = new Cesium.EllipsoidGeodesic( + new Cesium.Cartographic(start.longitude, start.latitude), + new Cesium.Cartographic(end.longitude, end.latitude), + ellipsoid, + ); + // Replay advances in real time across this path. Keep the geometry + // surface-safe, but give the animated marker and chase camera enough + // samples that they do not visibly pause at long segment boundaries. + const steps = Cesium.Math.clamp( + Math.ceil(geodesic.surfaceDistance / 75000), + 2, + 256, + ); + const positions = []; + for (let index = 0; index <= steps; index++) { + const fraction = index / steps; + const eased = fraction * fraction * (3 - 2 * fraction); + const cartographic = geodesic.interpolateUsingFraction( + fraction, + new Cesium.Cartographic(), + ); + cartographic.height = Cesium.Math.lerp(start.height, end.height, eased); + positions.push(ellipsoid.cartographicToCartesian(cartographic)); + } + positions[0] = startPosition; + positions[positions.length - 1] = endPosition; + return positions; + } + + function surfaceSafePath(controlPositions) { + if (!controlPositions?.length) return []; + if (controlPositions.length === 1) return controlPositions.slice(); + const path = []; + for (let index = 0; index < controlPositions.length - 1; index++) { + const segment = surfaceSafeSegment( + controlPositions[index], + controlPositions[index + 1], + ); + path.push(...(index === 0 ? segment : segment.slice(1))); + } + return path; + } + + function blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex) { + if (!ascentPath?.length || ascentPath.length < 4 || !orbitPath?.length) + return ascentPath; + const ellipsoid = Cesium.Ellipsoid.WGS84; + const transferEnd = orbitPath[insertionIndex]; + const nextOrbit = orbitPath[(insertionIndex + 1) % orbitPath.length]; + const orbitTangent = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract( + nextOrbit, + transferEnd, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + if ( + Cesium.Cartesian3.equalsEpsilon( + orbitTangent, + Cesium.Cartesian3.ZERO, + 1e-8, + ) + ) + return ascentPath; + + // Replace a substantial final section with one cubic Bézier transition. + // Matching both endpoint tangents avoids the short corrective hook produced + // by locally pulling only the last few ascent samples toward the orbit. + const blendCount = Math.min(52, ascentPath.length - 2); + const blendStart = ascentPath.length - 1 - blendCount; + const start = ascentPath[blendStart]; + const previous = ascentPath[Math.max(0, blendStart - 1)]; + const altitudeEnvelope = ascentPath + .slice(blendStart) + .map((position) => + Math.max(0, ellipsoid.cartesianToCartographic(position)?.height || 0), + ); + const ascentTangent = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(start, previous, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const chordLength = Math.max( + Cesium.Cartesian3.distance(start, transferEnd), + 1000, + ); + const handleLength = chordLength * 0.28; + const controlA = Cesium.Cartesian3.add( + start, + Cesium.Cartesian3.multiplyByScalar( + ascentTangent, + handleLength, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const controlB = Cesium.Cartesian3.add( + transferEnd, + Cesium.Cartesian3.multiplyByScalar( + orbitTangent, + -handleLength, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + for (let index = 0; index <= blendCount; index++) { + const progress = index / blendCount; + const inverse = 1 - progress; + const point = new Cesium.Cartesian3(); + Cesium.Cartesian3.multiplyByScalar(start, inverse ** 3, point); + Cesium.Cartesian3.add( + point, + Cesium.Cartesian3.multiplyByScalar( + controlA, + 3 * inverse ** 2 * progress, + new Cesium.Cartesian3(), + ), + point, + ); + Cesium.Cartesian3.add( + point, + Cesium.Cartesian3.multiplyByScalar( + controlB, + 3 * inverse * progress ** 2, + new Cesium.Cartesian3(), + ), + point, + ); + Cesium.Cartesian3.add( + point, + Cesium.Cartesian3.multiplyByScalar( + transferEnd, + progress ** 3, + new Cesium.Cartesian3(), + ), + point, + ); + // A Cartesian Bézier is a chord in world space and can pass through the + // ellipsoid when its orbit-tangent handle is long. Preserve the original + // climb's smooth altitude envelope while retaining the Bézier's horizontal + // curvature and tangent-aligned insertion. + const cartographic = ellipsoid.cartesianToCartographic(point); + const minimumHeight = altitudeEnvelope[index] ?? 0; + if (cartographic && cartographic.height < minimumHeight) { + cartographic.height = minimumHeight; + ascentPath[blendStart + index] = + ellipsoid.cartographicToCartesian(cartographic); + } else { + ascentPath[blendStart + index] = point; + } + } + ascentPath[ascentPath.length - 1] = transferEnd; + return ascentPath; + } + + /** + * Build a globe-safe ascent ending at the nearest point on the selected orbit. + * The returned orbit is rotated to begin at that same insertion point so the + * animated marker cannot jump between ascent and orbital phases. + * @param {Cesium.Cartesian3} launchPosition Launch-site position. + * @param {Cesium.Cartesian3[]} trajectoryPositions Optional upstream stage fixes. + * @param {Cesium.Cartesian3[]} orbitPath Selected satellite or estimated orbit. + * @param {Cesium.Cartesian3|null} [insertionReference] Propagated or estimated insertion position. + * @returns {{ascentPath: Cesium.Cartesian3[], animatedOrbitPath: Cesium.Cartesian3[], insertionIndex: number}} + */ + + function buildMissionPaths( + launchPosition, + trajectoryPositions, + orbitPath, + insertionReference = null, + ) { + const suppliedTrajectory = trajectoryPositions || []; + const controls = [launchPosition, ...suppliedTrajectory]; + const insertionIndex = nearestOrbitIndex( + orbitPath, + insertionReference || controls.at(-1), + ); + const transferEnd = orbitPath[insertionIndex]; + const ascentPath = suppliedTrajectory.length + ? surfaceSafePath([...controls, transferEnd]) + : reconstructedAscentPath(launchPosition, transferEnd); + blendAscentIntoOrbitTangent(ascentPath, orbitPath, insertionIndex); + return { + ascentPath, + animatedOrbitPath: orbitPathFromInsertion(orbitPath, insertionIndex), + insertionIndex, + }; + } + + function landingEndpoint(stage, launch, insertionPosition) { + if (Number.isFinite(stage.lat) && Number.isFinite(stage.lon)) { + return { lat: stage.lat, lon: stage.lon, accuracy: 'CONFIRMED' }; + } + const recoveryIdentity = + `${stage.recoveryType || ''} ${stage.destination || ''}`.toLowerCase(); + if ( + /return to launch site|rtls|launch site|landing zone/.test( + recoveryIdentity, + ) + ) { + return { lat: launch.lat, lon: launch.lon, accuracy: 'PAD / RTLS' }; + } + if (!(stage.downrangeKm > 0) || !insertionPosition) return null; + const ellipsoid = Cesium.Ellipsoid.WGS84; + const start = Cesium.Cartographic.fromDegrees(launch.lon, launch.lat); + const insertion = ellipsoid.cartesianToCartographic(insertionPosition); + if (!insertion) return null; + const geodesic = new Cesium.EllipsoidGeodesic(start, insertion, ellipsoid); + const bearing = geodesic.startHeading; + const angularDistance = + (stage.downrangeKm * 1000) / ellipsoid.maximumRadius; + const lat1 = start.latitude; + const lon1 = start.longitude; + const lat = Math.asin( + Math.sin(lat1) * Math.cos(angularDistance) + + Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(bearing), + ); + const lon = + lon1 + + Math.atan2( + Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(lat1), + Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat), + ); + return { + lat: Cesium.Math.toDegrees(lat), + lon: Cesium.Math.toDegrees(Cesium.Math.negativePiToPi(lon)), + accuracy: 'EST. DOWNRANGE', + }; + } + + function stageReentryRecoveryPath( + ascentPath, + endpoint, + stageIndex, + stageCount, + ) { + if (!ascentPath?.length || !endpoint) return []; + const progress = Cesium.Math.clamp( + 0.28 + (stageIndex / Math.max(stageCount, 1)) * 0.34, + 0.28, + 0.68, + ); + const separation = samplePath(ascentPath, progress); + const destination = Cesium.Cartesian3.fromDegrees( + endpoint.lon, + endpoint.lat, + 12, + ); + return surfaceSafeSegment(separation, destination); + } + + function atmosphericReentryIndex(path) { + if (!path?.length) return 0; + const ellipsoid = Cesium.Ellipsoid.WGS84; + for (let index = 1; index < path.length; index++) { + const previousHeight = ellipsoid.cartesianToCartographic( + path[index - 1], + )?.height; + const height = ellipsoid.cartesianToCartographic(path[index])?.height; + if ( + Number.isFinite(previousHeight) && + Number.isFinite(height) && + previousHeight > STAGE_REENTRY_ALTITUDE_M && + height <= STAGE_REENTRY_ALTITUDE_M + ) { + return index; + } + } + return Math.min( + Math.max(Math.round(path.length * 0.55), 0), + path.length - 1, + ); + } + return { + orbitInsertionOffsetSeconds, + estimatedOrbitPeriodSeconds, + approximateOrbitPath, + samplePath, + orbitProgressAtTime, + reconstructedAscentPath, + nearestOrbitIndex, + orbitPathFromInsertion, + surfaceSafeSegment, + surfaceSafePath, + blendAscentIntoOrbitTangent, + buildMissionPaths, + landingEndpoint, + stageReentryRecoveryPath, + atmosphericReentryIndex, + }; +} diff --git a/src/layers/launches/policy.js b/src/layers/launches/policy.js new file mode 100644 index 000000000..786aed79c --- /dev/null +++ b/src/layers/launches/policy.js @@ -0,0 +1,102 @@ +import * as Cesium from 'cesium'; + +export const WINDOW_DAYS = 30; + +export const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID = 'rocket-missions'; + +export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID = + 'rocket-mission-selected'; + +export const ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT = 48; + +export const ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY = 24; + +export const ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 12, + collisionCapacity: 0, + moving: true, + solveIntervalMs: 0, +}); + +export const ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT, + collisionCapacity: ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY, + moving: false, +}); + +export const REPLAY_ASCENT_FALLBACK_SEC = 12; + +export const REPLAY_ASCENT_MIN_SEC = 8; + +export const REPLAY_ASCENT_MAX_SEC = 36; + +export const REPLAY_ORBIT_DURATION_SEC = 28; + +export const REPLAY_COUNTDOWN_DURATION_SEC = 10; + +export const REPLAY_TILE_SETTLE_DELAY_SEC = 5; + +export const REPLAY_INITIAL_RANGE_M = 3500; + +export const REPLAY_LOCAL_MAX_RANGE_M = 900000; + +export const REPLAY_CONTEXT_MAX_RANGE_M = 2400000; + +export const REPLAY_CONTEXT_ALTITUDE_END_M = 420000; + +export const REPLAY_ORBIT_GLOBE_RANGE_M = 18000000; + +export const REPLAY_ORBIT_PULLBACK_FRACTION = 0.2; + +export const REPLAY_ASCENT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(30); + +export const REPLAY_ORBIT_CAMERA_OFFSET_RAD = Cesium.Math.toRadians(45); + +export const REPLAY_ORBIT_FRAME_CENTER_BLEND = 0.45; + +export const REPLAY_SPEED_MIN = 0.25; + +export const REPLAY_SPEED_MAX = 4; + +export const REPLAY_SPEED_STEP = 0.25; + +export const MAX_POST_TLE_RETRIES = 1; + +export const POST_TLE_RETRY_DELAY_MS = 1500; + +export const EARTH_ROTATION_RAD_PER_SEC = Cesium.Math.TWO_PI / 86164.0905; + +export const PROJECTED_ASCENT_ROTATION_SEC = 600; + +export const STAGE_REENTRY_ALTITUDE_M = 100000; + +export const MISSION_CLOSE_VIEW_RANGE_M = 180000; + +export const MISSION_GLOBE_VIEW_RANGE_M = 5000000; + +export const MISSION_FOCUS_RANGE_M = 12000; + +export const SATELLITE_STANDALONE_DEFAULTS = { + catalog: 'core', + showPoints: true, + showOrbits: true, +}; + +export const MISSION_ORBIT_PATTERN_GROUPS = 4; + +export const MISSION_ORBIT_DASHES_PER_GROUP = 100; + +export const LAUNCH_PAD_ZONE_RADIUS_M = 500; + +export const LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M = 120000; + +export const LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M = 180000; + +export const TRAJECTORY_STAGE_COLORS = [ + '#ff9f43', + '#ff66c4', + '#a78bfa', + '#7bed9f', + '#ffd166', + '#60a5fa', +]; diff --git a/src/layers/launches/policyHelpers.js b/src/layers/launches/policyHelpers.js new file mode 100644 index 000000000..c3cf77c07 --- /dev/null +++ b/src/layers/launches/policyHelpers.js @@ -0,0 +1,256 @@ +import * as Cesium from 'cesium'; +import { + SATELLITE_STANDALONE_DEFAULTS, + LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M, + LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M, +} from './policy.js'; + +export function createPolicyHelpers({ + state: layerState, + services, + parts, + source, +}) { + /** + * Derive the temporary Satellite display mode required by Space Missions. + * @param {object|null} currentParams Complete pre-mission Satellite parameters. + * @returns {object} Temporary mission-specific Satellite parameters. + */ + + function satelliteParamsForSpaceMissions(currentParams) { + return { + ...SATELLITE_STANDALONE_DEFAULTS, + ...(currentParams || {}), + catalog: 'dense', + showPoints: false, + showOrbits: false, + }; + } + + /** + * Resolve the complete Satellite parameter set restored after mission mode. + * @param {object|null} snapshot Complete pre-mission Satellite parameters. + * @returns {object} Standalone Satellite parameters. + */ + + function satelliteParamsAfterSpaceMissions(snapshot) { + return { + ...SATELLITE_STANDALONE_DEFAULTS, + ...(snapshot || {}), + }; + } + + /** + * Failed launch records may retain a planned orbit in Launch Library, but + * must not be represented as a live or estimated payload in orbit. + * @param {string|null} status Normalized Launch Library status name. + * @returns {boolean} Whether orbital visualization is allowed. + */ + + function launchStatusAllowsOrbit(status) { + return !/\b(?:fail(?:ed|ure)?|partial failure)\b/i.test( + String(status || ''), + ); + } + + /** + * Describe only the mission paths that the selected record can actually show. + * Launch Library may retain a target orbit after a failure, but that target is + * not evidence of orbital insertion and cannot support a reconstructed replay. + * @param {object|null} launch Normalized launch record. + * @param {boolean} replayAvailable Whether a rendered ascent/orbit track exists. + * @returns {{orbit: string|null, ascent: string, replayAvailable: boolean}} + */ + + function missionPathPresentation(launch, replayAvailable = false) { + const orbitName = + launch?.orbit?.name || + (typeof launch?.orbit === 'string' ? launch.orbit : null); + const orbitAllowed = launchStatusAllowsOrbit(launch?.status); + const suppliedTrajectoryPoints = Array.isArray(launch?.trajectory) + ? launch.trajectory.filter( + (point) => + Number.isFinite(Number(point?.latitude)) && + Number.isFinite(Number(point?.longitude)), + ).length + : 0; + return { + orbit: orbitName + ? `${orbitAllowed ? '' : 'PLANNED · '}${orbitName}` + : null, + ascent: + suppliedTrajectoryPoints > 1 + ? 'SUPPLIED TRAJECTORY POINTS' + : replayAvailable + ? 'RECONSTRUCTED ESTIMATE' + : 'UNAVAILABLE', + replayAvailable: Boolean(replayAvailable), + }; + } + + /** + * Release aircraft follow state through each owning flight layer before a + * mission replay takes over the camera. + * @param {object|null} dataManager DataLayerManager instance. + * @returns {number} Number of owner APIs invoked. + */ + + function releaseAircraftTracking(dataManager) { + let released = 0; + for (const layerId of ['flights', 'military']) { + const module = dataManager?.layers?.get(layerId)?.module; + if (typeof module?.stopTracking !== 'function') continue; + module.stopTracking(); + released++; + } + return released; + } + + /** + * Decide whether the selected launch-pad zone belongs in the current view. + * Both altitude and direct camera range are bounded so an oblique close-up can + * show the effect without leaking it into regional or globe views. + * @param {object} input Visibility inputs. + * @param {boolean} input.layerActive Whether Space Missions is active. + * @param {string|null} input.selectedLaunchId Selected mission identifier. + * @param {string} input.launchId Candidate mission identifier. + * @param {number} input.cameraHeightM Camera height above the ellipsoid. + * @param {number} input.cameraDistanceM Direct camera range to the launch pad. + * @returns {boolean} + */ + + function launchPadZoneVisible({ + layerActive, + selectedLaunchId, + launchId, + cameraHeightM, + cameraDistanceM, + }) { + return Boolean( + layerActive && + selectedLaunchId && + selectedLaunchId === launchId && + Number.isFinite(cameraHeightM) && + cameraHeightM <= LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M && + Number.isFinite(cameraDistanceM) && + cameraDistanceM <= LAUNCH_PAD_ZONE_MAX_CAMERA_DISTANCE_M, + ); + } + + /** + * Score the amount of useful mission context available for roster triage. + * @param {object} launch Normalized launch record. + * @returns {number} Completeness score. + */ + + function missionDataCompleteness(launch = {}) { + let score = 0; + const present = (value) => + value !== null && value !== undefined && value !== ''; + score += present(launch.provider) ? 1 : 0; + score += present(launch.mission) ? 2 : 0; + score += present(launch.missionName) ? 1 : 0; + score += present(launch.orbit?.name || launch.orbit) ? 2 : 0; + score += Array.isArray(launch.payloads) + ? Math.min(launch.payloads.length, 5) * 2 + : 0; + score += Array.isArray(launch.recoveryStages) + ? Math.min(launch.recoveryStages.length, 4) * 2 + : 0; + score += Array.isArray(launch.trajectory) + ? Math.min(launch.trajectory.length, 12) + : 0; + score += Array.isArray(launch.timeline) + ? Math.min(launch.timeline.length, 6) + : 0; + return score; + } + + /** + * Build a data-rich roster while preserving the source-array index + * used by mission selection and Previous/Next navigation. + * @param {Array} launches Normalized launch records. + * @returns {Array<{launch: object, index: number}>} + */ + + function missionRosterEntries(launches) { + return (launches || []) + .map((launch, index) => ({ launch, index })) + .sort((a, b) => { + const completeness = + missionDataCompleteness(b.launch) - missionDataCompleteness(a.launch); + if (completeness !== 0) return completeness; + const aTime = Date.parse(a.launch?.launchTime); + const bTime = Date.parse(b.launch?.launchTime); + if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) + return bTime - aTime; + return b.index - a.index; + }); + } + + /** + * Format a mission epoch for compact on-globe replay labels. + * @param {string|Date|null} launchTime ISO-8601 mission time or Date. + * @returns {string} UTC timestamp or a clear unavailable state. + */ + + function formatMissionEventTime(launchTime) { + const date = new Date(launchTime); + if (!launchTime || !Number.isFinite(date.getTime())) return 'UNAVAILABLE'; + return `${date.toISOString().slice(0, 10)}\n${date.toISOString().slice(11, 19)} UTC`; + } + + /** + * Parse the ISO-8601 durations supplied by Launch Library timeline events. + * @param {string|null} value ISO duration such as PT8M40S or -PT35M. + * @returns {number|null} Signed duration in seconds. + */ + + function parseMissionDurationSeconds(value) { + const match = String(value || '') + .trim() + .match( + /^(-)?P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/, + ); + if (!match) return null; + const seconds = + Number(match[2] || 0) * 86400 + + Number(match[3] || 0) * 3600 + + Number(match[4] || 0) * 60 + + Number(match[5] || 0); + return match[1] ? -seconds : seconds; + } + + function previewMissionFromRoster(launch) { + if (!layerState._viewer || !launch || layerState._selectedLaunchId) return; + const range = parts.panel.missionHoverPreviewRange( + layerState._viewer.camera.positionCartographic?.height, + ); + const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + layerState._viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(position, 0), + { + offset: new Cesium.HeadingPitchRange( + 0, + -Cesium.Math.PI_OVER_TWO, + range, + ), + duration: 0.8, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }, + ); + } + return { + satelliteParamsForSpaceMissions, + satelliteParamsAfterSpaceMissions, + launchStatusAllowsOrbit, + missionPathPresentation, + releaseAircraftTracking, + launchPadZoneVisible, + missionDataCompleteness, + missionRosterEntries, + formatMissionEventTime, + parseMissionDurationSeconds, + previewMissionFromRoster, + }; +} diff --git a/src/layers/launches/rendering.js b/src/layers/launches/rendering.js new file mode 100644 index 000000000..60e6e0070 --- /dev/null +++ b/src/layers/launches/rendering.js @@ -0,0 +1,573 @@ +import * as Cesium from 'cesium'; +import { + TRAJECTORY_STAGE_COLORS, + REPLAY_ORBIT_DURATION_SEC, + REPLAY_TILE_SETTLE_DELAY_SEC, + STAGE_REENTRY_ALTITUDE_M, +} from './policy.js'; + +export function createRendering({ + state: layerState, + services, + parts, + source, +}) { + const { getSatelliteOrbitTrack, findSatelliteOrbitTrackInTle } = + services.satellites; + + function setGraphicVisibility(graphic, visible, time) { + if (!graphic) return; + const next = Boolean(visible); + const current = graphic.show?.getValue?.(time); + if (current !== next) graphic.show = next; + } + + function updateMissionFrame() { + if ( + !layerState._enabled || + !layerState._viewer || + !layerState._dataSource?.show + ) { + parts.overlays.hideReplayVehicleOverlay(); + return; + } + parts.panel.updateMissionTelemetry(); + const time = Cesium.JulianDate.now(layerState._declutterTime); + layerState._declutterOccluder.cameraPosition = + layerState._viewer.scene.camera.positionWC; + parts.overlays.updateReplayVehicleOverlay(layerState._declutterOccluder); + parts.overlays.refreshSelectedMissionOverlayText(); + for (const entity of layerState._dataSource.entities.values) { + if (!entity.show) continue; + if (!entity.position) continue; + const id = parts.selection.entityLaunchId(entity); + if (!id) continue; + const position = entity.position.getValue(time); + let horizonVisible = Boolean( + position && layerState._declutterOccluder.isPointVisible(position), + ); + const launchAnchor = entity.id.startsWith('rocket-launch:'); + if (launchAnchor) { + horizonVisible = parts.overlays.missionAnchorVisible( + layerState._viewer.scene.camera.positionWC, + position, + id, + layerState._selectedLaunchId, + ); + // Keep entity.show reserved for mission-selection isolation. Horizon + // state is applied to the individual graphics below so a rear-side + // anchor remains eligible for reevaluation as the camera moves. + } + if (entity.point) { + setGraphicVisibility(entity.point, horizonVisible, time); + } + if (launchAnchor && entity.billboard) { + setGraphicVisibility( + entity.billboard, + horizonVisible && id === layerState._hoveredRosterLaunchId, + time, + ); + } + } + } + + function addLaunchEntity(launch, activeTleText = layerState._activeTleText) { + const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + const overlayRecord = { + launch, + anchorPosition: position, + elementEntryFactories: [], + liveEventTime: null, + }; + layerState._missionOverlayRecords.set(launch.id, overlayRecord); + const orbitAllowed = parts.policyHelpers.launchStatusAllowsOrbit( + launch.status, + ); + const coreTrack = + orbitAllowed && launch.satelliteQuery + ? getSatelliteOrbitTrack(launch.satelliteQuery, { + launchTime: launch.launchTime, + }) + : null; + const satelliteTrack = + coreTrack || + (orbitAllowed && activeTleText && launch.satelliteQuery + ? findSatelliteOrbitTrackInTle(activeTleText, launch.satelliteQuery, { + launchTime: launch.launchTime, + }) + : null); + const orbitPath = !orbitAllowed + ? null + : satelliteTrack?.orbitPath?.length > 1 + ? satelliteTrack.orbitPath + : parts.paths.approximateOrbitPath(launch); + launch.recoveryStages.forEach((stage) => { + stage.endpoint = parts.paths.landingEndpoint( + stage, + launch, + orbitPath?.[0] || null, + ); + }); + if (satelliteTrack) layerState._orbitMatches++; + const entity = layerState._dataSource.entities.add({ + id: `rocket-launch:${launch.id}`, + position, + point: { + pixelSize: 5, + color: parts.model.missionMarkerColor(launch), + outlineColor: parts.model.missionMarkerColor(launch), + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + billboard: { + image: parts.overlays.missionHoverReticleImage(), + width: 24, + height: 24, + show: false, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + launchId: launch.id, + launchName: launch.name, + launchTime: launch.launchTime, + launchSite: launch.launchSite, + provider: launch.provider, + mission: launch.mission, + status: launch.status, + source: launch.source, + orbit: JSON.stringify(launch.orbit || {}), + satelliteName: satelliteTrack?.name || '', + noradId: satelliteTrack?.noradId || '', + }, + }); + const points = launch.trajectory + .filter( + (point) => + Number.isFinite(Number(point.latitude)) && + Number.isFinite(Number(point.longitude)), + ) + .map((point) => ({ + stage: String( + point.stage || + point.stage_name || + point.phase || + point.stageName || + 'trajectory', + ), + position: Cesium.Cartesian3.fromDegrees( + Number(point.longitude), + Number(point.latitude), + Number(point.altitude || 0), + ), + })); + if (points.length > 1) { + const segments = []; + points.forEach((point) => { + const previous = segments.at(-1); + if (!previous || previous.stage !== point.stage) + segments.push({ stage: point.stage, positions: [] }); + segments.at(-1).positions.push(point.position); + }); + segments.forEach((segment, index) => { + if (segment.positions.length < 2) return; + layerState._dataSource.entities.add({ + id: `rocket-trajectory:${launch.id}:${index}`, + polyline: { + positions: parts.paths.surfaceSafePath(segment.positions), + width: 2, + material: Cesium.Color.fromCssColorString( + TRAJECTORY_STAGE_COLORS[index % TRAJECTORY_STAGE_COLORS.length], + ).withAlpha(0.8), + clampToGround: false, + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + stage: segment.stage, + source: launch.source, + }, + }); + }); + } + if (orbitPath?.length > 1) { + const orbitCurrent = satelliteTrack?.current || { + longitude: launch.lon, + latitude: launch.lat, + altitude: 0, + }; + const orbitPeriodSec = + satelliteTrack?.periodSec || + parts.paths.estimatedOrbitPeriodSeconds(orbitPath); + const launchEpochMs = Date.parse(launch.launchTime); + const disclosedInsertionOffsetSec = + parts.paths.orbitInsertionOffsetSeconds(launch); + const insertionDurationSec = Number.isFinite(disclosedInsertionOffsetSec) + ? Math.max(0, disclosedInsertionOffsetSec) + : 600; + const insertionEpochMs = Number.isFinite(launchEpochMs) + ? launchEpochMs + insertionDurationSec * 1000 + : Number.NaN; + let insertionReference = points.at(-1)?.position || null; + if ( + !insertionReference && + satelliteTrack?.positionAt && + Number.isFinite(insertionEpochMs) + ) { + const propagatedInsertion = satelliteTrack.positionAt( + new Date(insertionEpochMs), + ); + if (propagatedInsertion) { + insertionReference = Cesium.Cartesian3.fromDegrees( + propagatedInsertion.longitude, + propagatedInsertion.latitude, + propagatedInsertion.altitude, + ); + } + } + if (!insertionReference && !satelliteTrack) { + // A projected orbit has no authoritative historical phase. Start its + // plane over the launch site, advance only by the estimated powered + // ascent duration, and join the forward orbit tangent. Using the UTC + // epoch as phase sent some ascents toward the far side of Earth and + // forced a visible 180-degree corrective hook before insertion. + const insertionProgress = Cesium.Math.clamp( + insertionDurationSec / orbitPeriodSec, + 1 / 96, + 0.16, + ); + insertionReference = parts.paths.samplePath( + orbitPath, + insertionProgress, + ); + } + const { ascentPath, animatedOrbitPath } = parts.paths.buildMissionPaths( + position, + points.map((point) => point.position), + orbitPath, + insertionReference, + ); + const ascentDurationSec = parts.replay.replayAscentDurationSeconds( + launch, + ascentPath, + ); + // Cesium can evaluate CallbackProperty values multiple times while it + // traverses one frame. Camera tracking and the HTML replay overlay must + // use the exact same replay epoch as those callbacks; sampling Date.now() + // independently made the vehicle advance relative to the camera and then + // snap back on the next frame. + let replayStateForFrame = null; + const sampleReplayState = () => + parts.replay.replayState( + launch, + layerState._animationStarts.get(launch.id) || Date.now(), + ascentDurationSec, + REPLAY_ORBIT_DURATION_SEC, + orbitPeriodSec, + layerState._replaySpeed, + parts.replay.replayClockNow(launch.id), + REPLAY_TILE_SETTLE_DELAY_SEC, + layerState._replayCameraLaunchId !== launch.id, + ); + // preUpdate runs before Cesium evaluates entity CallbackProperties. Seed + // one state there and retain it through postRender so every consumer in + // that traversal sees the same timestamp. scene.frameState.frameNumber is + // advanced between some of those phases, so it is not a reliable cache key. + const beginReplayFrame = () => { + replayStateForFrame = sampleReplayState(); + return replayStateForFrame; + }; + const getReplayState = () => { + if (!replayStateForFrame) { + replayStateForFrame = sampleReplayState(); + } + return replayStateForFrame; + }; + launch.recoveryStages.forEach((stage, stageIndex) => { + stage.endpoint = parts.paths.landingEndpoint( + stage, + launch, + ascentPath.at(-1), + ); + const path = parts.paths.stageReentryRecoveryPath( + ascentPath, + stage.endpoint, + stageIndex, + launch.recoveryStages.length, + ); + if (path.length < 2) return; + const reentryIndex = parts.paths.atmosphericReentryIndex(path); + const reentryPath = path.slice(reentryIndex); + const color = Cesium.Color.fromCssColorString( + TRAJECTORY_STAGE_COLORS[ + (stageIndex + 1) % TRAJECTORY_STAGE_COLORS.length + ], + ); + layerState._dataSource.entities.add({ + id: `rocket-reentry-recovery:${launch.id}:${stageIndex}`, + polyline: { + positions: path, + width: 2, + material: new Cesium.PolylineDashMaterialProperty({ + color: color.withAlpha(0.82), + dashLength: 12, + dashPattern: 0xaaaa, + }), + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + stageId: stage.id, + phase: 'STAGE_REENTRY_RECOVERY', + accuracy: stage.endpoint.accuracy, + source: launch.source, + }, + }); + if (reentryPath.length > 1) { + layerState._dataSource.entities.add({ + id: `rocket-reentry:${launch.id}:${stageIndex}`, + polyline: { + positions: reentryPath, + width: 2.5, + material: new Cesium.PolylineDashMaterialProperty({ + color: + Cesium.Color.fromCssColorString('#ffd166').withAlpha(0.9), + dashLength: 8, + dashPattern: 0xf0f0, + }), + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + stageId: stage.id, + phase: 'ATMOSPHERIC_REENTRY', + altitudeM: STAGE_REENTRY_ALTITUDE_M, + source: 'Estimated 100 km atmospheric interface', + }, + }); + const reentryPosition = path[reentryIndex]; + overlayRecord.elementEntryFactories.push(() => + parts.overlays.createRocketMissionElementOverlayEntry({ + id: `reentry:${launch.id}:${stageIndex}`, + position: reentryPosition, + text: 'STAGE RE-ENTRY', + accent: '#ffd166', + priority: 700_000 - stageIndex, + gapPx: 8, + }), + ); + } + layerState._dataSource.entities.add({ + id: `rocket-recovery-end:${launch.id}:${stageIndex}`, + position: path.at(-1), + point: { + pixelSize: 4, + color, + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + launchId: launch.id, + stageId: stage.id, + phase: 'RECOVERY_ENDPOINT', + source: launch.source, + }, + }); + }); + layerState._replayTracks.set(launch.id, { + ascentPath, + animatedOrbitPath, + orbitFrameSphere: + parts.camera.replayOrbitFrameSphere(animatedOrbitPath), + ascentDurationSec, + beginReplayFrame, + getReplayState, + lastCameraHeading: Math.PI, + orbitCameraWorldFrame: false, + lastVehicleRotation: 0, + lastOverlayWindowPosition: null, + lastOverlayMode: null, + }); + let livePosition = Cesium.Cartesian3.fromDegrees( + orbitCurrent.longitude, + orbitCurrent.latitude, + orbitCurrent.altitude, + ); + const liveTime = new Date(); + const liveTelemetry = { speedMps: null }; + layerState._satelliteTelemetry.set(launch.id, liveTelemetry); + let liveFrameNumber = -1; + const fallbackPeriodSec = + parts.paths.estimatedOrbitPeriodSeconds(orbitPath); + const updateLiveState = () => { + const frameNumber = + layerState._viewer?.scene?.frameState?.frameNumber ?? -1; + if (frameNumber !== -1 && frameNumber === liveFrameNumber) return; + liveFrameNumber = frameNumber; + const nowMs = Date.now(); + liveTime.setTime(nowMs); + if (satelliteTrack) { + const propagated = + satelliteTrack.positionAt?.(liveTime) || satelliteTrack.current; + if (propagated) { + Cesium.Cartesian3.fromDegrees( + propagated.longitude, + propagated.latitude, + propagated.altitude, + undefined, + livePosition, + ); + liveTelemetry.speedMps = Number.isFinite(propagated.speedMps) + ? propagated.speedMps + : null; + } + } else { + // In-place write keeps one Cartesian3 for the mission's lifetime — + // the estimated branch previously reallocated per propagation. + livePosition = + parts.paths.samplePath( + orbitPath, + parts.paths.orbitProgressAtTime(nowMs, fallbackPeriodSec), + livePosition, + ) || livePosition; + } + }; + layerState._dataSource.entities.add({ + id: `rocket-satellite:${launch.id}`, + position: new Cesium.CallbackProperty(() => { + updateLiveState(); + return livePosition; + }, false), + point: { + pixelSize: 6, + color: Cesium.Color.fromCssColorString( + satelliteTrack ? '#7bed9f' : '#ffd166', + ), + outlineWidth: 0, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + launchId: launch.id, + noradId: satelliteTrack?.noradId || '', + phase: satelliteTrack + ? 'CURRENT_SATELLITE_POSITION' + : 'ESTIMATED_ORBIT_POSITION', + source: satelliteTrack + ? 'Satellites layer' + : 'Approximate mission orbit', + }, + }); + overlayRecord.liveEventTime = () => liveTime; + overlayRecord.elementEntryFactories.push(() => { + const name = satelliteTrack + ? parts.overlays + .shortMissionLabel(satelliteTrack.name, 22) + .toUpperCase() + : 'EST. ORBIT POSITION'; + return parts.overlays.createRocketMissionElementOverlayEntry({ + id: `payload-position:${launch.id}`, + position: () => livePosition, + text: `${name}\n${parts.policyHelpers.formatMissionEventTime(liveTime)}`, + accent: satelliteTrack ? '#7bed9f' : '#ffd166', + priority: 900_000, + gapPx: 12, + }); + }); + // Live satellite rings use a primitive collection so the core Satellite + // GMST transform can rotate their baked ECEF geometry without rebuilding + // it. Estimated launch-site-relative rings remain ordinary entities. + const primitiveOrbitAdded = parts.orbitRendering.addMissionOrbitPrimitive( + launch, + orbitPath, + satelliteTrack, + ); + if (!primitiveOrbitAdded) { + layerState._dataSource.entities.add({ + id: `rocket-orbit:${launch.id}`, + polyline: { + positions: orbitPath, + // The 3 px canvas gives the periodic dot room to read as round; the + // shader masks the ten intervening dashes down to a thin center stroke. + width: 3, + material: + new parts.orbitRendering.MissionOrbitPatternMaterialProperty( + Cesium.Color.fromCssColorString('#c084fc').withAlpha(0.95), + ), + arcType: Cesium.ArcType.NONE, + }, + properties: { + launchId: launch.id, + satelliteName: '', + noradId: '', + source: 'Approximate mission orbit', + }, + }); + } + const orbitLabelBakePosition = ascentPath.at(-1); + let orbitLabelPosition = orbitLabelBakePosition; + const primitivePath = layerState._missionOrbitPrimitives.get(launch.id); + if (primitivePath) { + primitivePath.labelBakePosition = orbitLabelBakePosition; + primitivePath.labelPosition = Cesium.Matrix4.multiplyByPoint( + primitivePath.primitive.modelMatrix, + orbitLabelBakePosition, + new Cesium.Cartesian3(), + ); + orbitLabelPosition = () => primitivePath.labelPosition; + } + overlayRecord.elementEntryFactories.push(() => + parts.overlays.createRocketMissionElementOverlayEntry({ + id: `orbit:${launch.id}`, + position: orbitLabelPosition, + text: satelliteTrack ? 'ORBIT' : 'PROJECTED ORBIT', + accent: satelliteTrack ? '#22e6e6' : '#c084fc', + priority: 800_000, + gapPx: 8, + }), + ); + const replayPosition = new Cesium.CallbackProperty(() => { + const state = getReplayState(); + return state.ascending + ? parts.paths.samplePath(ascentPath, state.phaseProgress) + : parts.paths.samplePath(animatedOrbitPath, state.phaseProgress); + }, false); + layerState._dataSource.entities.add({ + id: `rocket-vehicle:${launch.id}`, + position: replayPosition, + properties: { + launchId: launch.id, + noradId: satelliteTrack?.noradId || '', + phase: 'ASCENT_THEN_ORBIT', + source: satelliteTrack + ? 'Satellite trajectory animation' + : 'Approximate trajectory animation', + }, + }); + layerState._dataSource.entities.add({ + id: `rocket-transfer:${launch.id}`, + polyline: { + positions: ascentPath, + width: 1.5, + // The animated vehicle uses Cartesian interpolation between these + // exact samples. Prevent Cesium from replacing each segment with a + // geodesic arc, which would visually separate the dot from the line. + arcType: Cesium.ArcType.NONE, + material: new Cesium.PolylineDashMaterialProperty({ + color: Cesium.Color.fromCssColorString('#7bed9f').withAlpha(0.8), + dashLength: 24, + dashPattern: 0xf0f0, + }), + }, + properties: { + launchId: launch.id, + phase: 'APPROXIMATE_TRANSFER', + source: satelliteTrack + ? 'Launch site to propagated insertion' + : 'Launch site to forward projected insertion', + }, + }); + } + } + return { setGraphicVisibility, updateMissionFrame, addLaunchEntity }; +} diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js new file mode 100644 index 000000000..e6cbff98b --- /dev/null +++ b/src/layers/launches/replay.js @@ -0,0 +1,557 @@ +import * as Cesium from 'cesium'; +import { + REPLAY_ASCENT_FALLBACK_SEC, + REPLAY_ASCENT_MIN_SEC, + REPLAY_ASCENT_MAX_SEC, + REPLAY_SPEED_MIN, + REPLAY_SPEED_MAX, + REPLAY_SPEED_STEP, + REPLAY_TILE_SETTLE_DELAY_SEC, + REPLAY_COUNTDOWN_DURATION_SEC, + REPLAY_ORBIT_PULLBACK_FRACTION, + REPLAY_ORBIT_DURATION_SEC, + REPLAY_INITIAL_RANGE_M, +} from './policy.js'; + +export function createReplay({ state: layerState, services, parts, source }) { + /** + * Rotate an upright screen-space rocket so its nose follows a projected path. + * @param {{x: number, y: number}} from Current screen point. + * @param {{x: number, y: number}} to Forward screen point. + * @returns {number} Clockwise CSS rotation in radians. + */ + + function replayVehicleScreenRotation(from, to) { + const dx = Number(to?.x) - Number(from?.x); + const dy = Number(to?.y) - Number(from?.y); + if ( + !Number.isFinite(dx) || + !Number.isFinite(dy) || + Math.hypot(dx, dy) < 0.01 + ) + return 0; + return Math.atan2(dx, -dy); + } + + /** + * Reduce small screen-space reprojection jitter without allowing the marker + * to lag behind a camera jump or a phase transition. + * @param {{x:number,y:number}|null} previous Previous rendered position. + * @param {{x:number,y:number}} next Current path projection. + * @param {number} [alpha] Interpolation amount. + * @param {number} [snapDistance] Maximum distance to smooth. + * @returns {{x:number,y:number}} + */ + + function smoothReplayWindowPosition( + previous, + next, + alpha = 0.55, + snapDistance = 24, + ) { + if (!previous || !next) return next; + const distance = Math.hypot(next.x - previous.x, next.y - previous.y); + if (!Number.isFinite(distance) || distance > snapDistance) return next; + const amount = Cesium.Math.clamp(Number(alpha) || 0, 0, 1); + return { + x: Cesium.Math.lerp(previous.x, next.x, amount), + y: Cesium.Math.lerp(previous.y, next.y, amount), + }; + } + + /** + * Derive a compressed but mission-specific ascent replay duration. + * Launch Library timelines are authoritative when they expose insertion, + * SECO, or separation timing. Sparse records fall back to the reconstructed + * path length and a conservative ascent velocity estimate. + * @param {object} launch Normalized launch record. + * @param {Cesium.Cartesian3[]} ascentPath Reconstructed or supplied ascent. + * @returns {number} Replay duration in seconds. + */ + + function replayAscentDurationSeconds(launch, ascentPath = []) { + const disclosedSeconds = parts.paths.orbitInsertionOffsetSeconds(launch); + let realAscentSeconds = disclosedSeconds > 0 ? disclosedSeconds : null; + if (!realAscentSeconds && ascentPath.length > 1) { + const pathLength = ascentPath + .slice(1) + .reduce( + (total, point, index) => + total + Cesium.Cartesian3.distance(ascentPath[index], point), + 0, + ); + realAscentSeconds = Math.max(180, pathLength / 9000); + } + if (!realAscentSeconds) realAscentSeconds = REPLAY_ASCENT_FALLBACK_SEC * 50; + return Cesium.Math.clamp( + REPLAY_ASCENT_FALLBACK_SEC * (realAscentSeconds / 600), + REPLAY_ASCENT_MIN_SEC, + REPLAY_ASCENT_MAX_SEC, + ); + } + + /** + * Clamp and snap a replay speed multiplier to the supported slider range. + * @param {number|string} value Requested playback multiplier. + * @returns {number} Supported multiplier between 0.25x and 4x. + */ + + function normalizeReplaySpeed(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 1; + const clamped = Cesium.Math.clamp( + numeric, + REPLAY_SPEED_MIN, + REPLAY_SPEED_MAX, + ); + return Math.round(clamped / REPLAY_SPEED_STEP) * REPLAY_SPEED_STEP; + } + + /** + * Preserve replay elapsed time when resuming after a pause. + * @param {number} startedAt Original replay start epoch in milliseconds. + * @param {number} pausedAt Pause epoch in milliseconds. + * @param {number} resumedAt Resume epoch in milliseconds. + * @returns {number} Shifted start epoch. + */ + + function replayStartAfterPause(startedAt, pausedAt, resumedAt) { + if (![startedAt, pausedAt, resumedAt].every(Number.isFinite)) + return startedAt; + return startedAt + Math.max(0, resumedAt - pausedAt); + } + + function replayState( + launch, + startedAt, + ascentDurationSec, + orbitDurationSec, + orbitPeriodSec, + speed = 1, + nowMs = Date.now(), + preCountdownDurationSec = 0, + loop = true, + ) { + const animationDurationSec = ascentDurationSec + orbitDurationSec; + const realSecondsSinceStart = (nowMs - startedAt) / 1000; + const preCountdownDuration = Math.max( + 0, + Number(preCountdownDurationSec) || 0, + ); + const preCountdownActive = + preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration; + const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive; + const countdownSeconds = countdownActive + ? Math.ceil(-realSecondsSinceStart) + : 0; + const elapsedSinceStart = Math.max( + 0, + realSecondsSinceStart * normalizeReplaySpeed(speed), + ); + const elapsed = loop + ? elapsedSinceStart % animationDurationSec + : Math.min(elapsedSinceStart, Math.max(0, animationDurationSec - 1e-6)); + const insertionOffsetSec = parts.paths.orbitInsertionOffsetSeconds(launch); + const ascending = elapsed < ascentDurationSec; + const phaseProgress = ascending + ? elapsed / ascentDurationSec + : (elapsed - ascentDurationSec) / orbitDurationSec; + const missionOffsetSec = + insertionOffsetSec === null + ? null + : ascending + ? phaseProgress * insertionOffsetSec + : insertionOffsetSec + phaseProgress * orbitPeriodSec; + const launchEpoch = Date.parse(launch.launchTime); + const eventTime = + Number.isFinite(launchEpoch) && missionOffsetSec !== null + ? new Date(launchEpoch + missionOffsetSec * 1000) + : null; + return { + ascending, + phaseProgress, + eventTime, + elapsedSinceStart, + countdownActive, + preCountdownActive, + countdownSeconds, + }; + } + + function syncReplayButton() { + const button = layerState._missionPanel?.querySelector( + '[data-mission-replay]', + ); + const transport = layerState._missionPanel?.querySelector( + '[data-mission-replay-transport]', + ); + const speedControl = layerState._missionPanel?.querySelector( + '.mission-replay-speed-control', + ); + if (!button) return; + const active = Boolean( + layerState._replayCameraLaunchId && + layerState._replayCameraLaunchId === layerState._selectedLaunchId, + ); + const replayAvailable = Boolean( + layerState._selectedLaunchId && + layerState._replayTracks.has(layerState._selectedLaunchId), + ); + if (speedControl) speedControl.hidden = !replayAvailable; + button.hidden = active || !replayAvailable; + button.disabled = !replayAvailable; + button.textContent = 'REPLAY ASCENT'; + button.classList.remove('active'); + button.setAttribute('aria-pressed', String(active)); + button.title = 'Replay the estimated ascent with a following camera'; + if (transport) { + transport.hidden = !active; + transport.classList.toggle( + 'is-paused', + active && layerState._replayPaused, + ); + const toggleButton = transport.querySelector( + '[data-mission-replay-toggle]', + ); + if (toggleButton) { + toggleButton.disabled = !active; + toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ'; + toggleButton.setAttribute( + 'aria-label', + layerState._replayPaused ? 'Resume replay' : 'Pause replay', + ); + toggleButton.title = layerState._replayPaused + ? 'Resume replay' + : 'Pause replay'; + } + } + } + + function syncReplayCountdownButton(state) { + const transport = layerState._missionPanel?.querySelector( + '[data-mission-replay-transport]', + ); + if (!transport || !layerState._replayCameraLaunchId) return; + const phase = state.countdownActive + ? `T minus ${state.countdownSeconds}` + : state.preCountdownActive + ? 'Preparing launch site' + : state.elapsedSinceStart < 1 + ? 'Liftoff' + : state.ascending + ? 'Ascent replay' + : 'Orbit replay'; + transport.setAttribute( + 'aria-label', + `${phase}${layerState._replayPaused ? ', paused' : ''}`, + ); + } + + function syncReplaySpeedControl() { + const input = layerState._missionPanel?.querySelector( + '[data-mission-replay-speed]', + ); + const output = layerState._missionPanel?.querySelector( + '[data-mission-replay-speed-output]', + ); + if (input) { + input.value = String(layerState._replaySpeed); + const progress = + ((layerState._replaySpeed - REPLAY_SPEED_MIN) / + (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * + 100; + input.style.setProperty('--replay-speed-progress', `${progress}%`); + } + if (output) + output.textContent = `${layerState._replaySpeed.toFixed(layerState._replaySpeed % 1 ? 2 : 0)}×`; + } + + function setReplaySpeed(value) { + const nextSpeed = normalizeReplaySpeed(value); + const previousSpeed = layerState._replaySpeed; + if (nextSpeed === previousSpeed) { + syncReplaySpeedControl(); + return; + } + const now = + layerState._replayPaused && Number.isFinite(layerState._replayPausedAtMs) + ? layerState._replayPausedAtMs + : Date.now(); + for (const [launchId, startedAt] of layerState._animationStarts) { + if (!Number.isFinite(startedAt)) continue; + const elapsedMissionMs = (now - startedAt) * previousSpeed; + layerState._animationStarts.set( + launchId, + now - elapsedMissionMs / nextSpeed, + ); + } + layerState._replaySpeed = nextSpeed; + syncReplaySpeedControl(); + } + + function replayClockNow(launchId) { + if ( + layerState._replayPaused && + layerState._replayCameraLaunchId === launchId && + Number.isFinite(layerState._replayPausedAtMs) + ) { + return layerState._replayPausedAtMs; + } + return Date.now(); + } + + function pauseMissionReplay() { + if (!layerState._replayCameraLaunchId || layerState._replayPaused) + return false; + layerState._replayPausedAtMs = Date.now(); + layerState._replayPaused = true; + syncReplayButton(); + return true; + } + + function resumeMissionReplay() { + if ( + !layerState._replayCameraLaunchId || + !layerState._replayPaused || + !Number.isFinite(layerState._replayPausedAtMs) + ) { + return false; + } + const resumedAt = Date.now(); + const startedAt = layerState._animationStarts.get( + layerState._replayCameraLaunchId, + ); + if (Number.isFinite(startedAt)) { + layerState._animationStarts.set( + layerState._replayCameraLaunchId, + replayStartAfterPause( + startedAt, + layerState._replayPausedAtMs, + resumedAt, + ), + ); + } + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + syncReplayButton(); + return true; + } + + function stopMissionReplay() { + const stoppedLaunchId = layerState._replayCameraLaunchId; + layerState._replayCameraToken++; + if (layerState._replayCameraRemover) layerState._replayCameraRemover(); + layerState._replayCameraRemover = null; + layerState._viewer?.camera?.cancelFlight(); + if (layerState._viewer?.camera) + layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + layerState._replayCameraLaunchId = null; + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + if (stoppedLaunchId) + layerState._animationStarts.set(stoppedLaunchId, Date.now()); + syncReplayButton(); + parts.overlays.syncMissionOverlayEntries(); + } + + function startMissionReplay(launchId) { + const launch = layerState._launches.find((item) => item.id === launchId); + const track = layerState._replayTracks.get(launchId); + if (!layerState._viewer || !launch || !track) return false; + if (layerState._replayCameraLaunchId === launchId) { + stopMissionReplay(); + return false; + } + + parts.selection.stopMissionZoomAnchor(); + stopMissionReplay(); + layerState._replayCameraLaunchId = launchId; + layerState._replayPaused = false; + layerState._replayPausedAtMs = null; + const token = ++layerState._replayCameraToken; + const ascentDurationSec = track.ascentDurationSec; + // Start broadside to the ascent/orbit direction so the launch profile is + // visible. The chase heading then eases toward the path tangent after the + // camera is established. + const initialHeading = parts.camera.replayInitialCameraHeading( + track.ascentPath, + ); + track.lastCameraHeading = initialHeading; + syncReplayButton(); + parts.overlays.syncMissionOverlayEntries(); + parts.policyHelpers.releaseAircraftTracking(layerState._dataManager); + layerState._viewer.trackedEntity = undefined; + layerState._viewer.camera.cancelFlight(); + layerState._animationStarts.set( + launchId, + Date.now() + + (REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000, + ); + let cameraReady = false; + let lastCameraUpdateMs = null; + + // Move the chase camera before scene traversal. Mutating it in preRender + // makes Cesium discover a new view after 3D-tile refinement, which can cause + // a self-sustaining refinement loop and visible stutter during slow replay. + layerState._replayCameraRemover = + layerState._viewer.scene.preUpdate.addEventListener(() => { + if ( + token !== layerState._replayCameraToken || + layerState._replayCameraLaunchId !== launchId + ) + return; + const state = track.beginReplayFrame(); + syncReplayCountdownButton(state); + if (!cameraReady) return; + const path = state.ascending + ? track.ascentPath + : track.animatedOrbitPath; + const position = parts.paths.samplePath(path, state.phaseProgress); + if (!position) return; + const pathHeading = parts.camera.cameraHeadingForPath( + path, + state.phaseProgress, + track.lastCameraHeading, + ); + const orbitBlend = !state.ascending + ? Cesium.Math.clamp( + (Number(state.phaseProgress) || 0) / + REPLAY_ORBIT_PULLBACK_FRACTION, + 0, + 1, + ) + : 0; + const desiredHeading = parts.camera.replayChaseCameraHeading( + pathHeading, + orbitBlend, + ); + if (state.ascending) track.orbitCameraWorldFrame = false; + const nowMs = performance.now(); + const frameDurationMs = Number.isFinite(lastCameraUpdateMs) + ? Cesium.Math.clamp(nowMs - lastCameraUpdateMs, 4, 50) + : 1000 / 60; + lastCameraUpdateMs = nowMs; + track.lastCameraHeading = parts.camera.smoothReplayCameraHeading( + track.lastCameraHeading, + desiredHeading, + (Cesium.Math.toRadians(2) * frameDurationMs) / (1000 / 60), + ); + const cartographic = + Cesium.Ellipsoid.WGS84.cartesianToCartographic(position); + const altitude = Math.max(0, cartographic?.height || 0); + const cameraView = parts.camera.replayCameraView(state, altitude); + const defaultOrbitTarget = !state.ascending + ? parts.camera.replayOrbitGlobeAnchor(position, orbitBlend) + : position; + const cameraTarget = !state.ascending + ? parts.camera.replayOrbitCameraTarget( + defaultOrbitTarget, + track.orbitFrameSphere?.center, + orbitBlend, + ) + : defaultOrbitTarget; + const cameraRange = !state.ascending + ? parts.camera.replayOrbitGlobeRange( + cameraView.range, + altitude, + orbitBlend, + track.orbitFrameSphere?.radius, + ) + : cameraView.range; + if (state.ascending) { + layerState._viewer.camera.lookAt( + cameraTarget, + new Cesium.HeadingPitchRange( + track.lastCameraHeading, + cameraView.pitch, + cameraRange, + ), + ); + } else { + const tangentStep = Math.max( + 0.002, + 0.8 / Math.max(2, path.length - 1), + ); + const tangentProgress = Math.min( + 1, + state.phaseProgress + tangentStep, + ); + const tangentPosition = parts.paths.samplePath( + path, + tangentProgress > state.phaseProgress + ? tangentProgress + : Math.max(0, state.phaseProgress - tangentStep), + ); + const orbitPose = parts.camera.replayOrbitCameraPose( + position, + tangentPosition, + cameraTarget, + cameraRange, + cameraView.pitch, + ); + if (orbitPose) { + if (!track.orbitCameraWorldFrame) { + layerState._viewer.camera.lookAtTransform( + Cesium.Matrix4.IDENTITY, + ); + track.orbitCameraWorldFrame = true; + } + layerState._viewer.camera.setView({ + destination: orbitPose.destination, + orientation: { + direction: orbitPose.direction, + up: orbitPose.up, + }, + }); + } + } + if ( + state.elapsedSinceStart >= + ascentDurationSec + REPLAY_ORBIT_DURATION_SEC + ) { + stopMissionReplay(); + } + }); + + layerState._viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(track.ascentPath[0], 0), + { + offset: new Cesium.HeadingPitchRange( + initialHeading, + Cesium.Math.toRadians(-20), + REPLAY_INITIAL_RANGE_M, + ), + duration: 1.4, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete: () => { + cameraReady = true; + }, + cancel: () => { + if ( + token === layerState._replayCameraToken && + layerState._replayCameraLaunchId === launchId + ) + stopMissionReplay(); + }, + }, + ); + return true; + } + return { + replayVehicleScreenRotation, + smoothReplayWindowPosition, + replayAscentDurationSeconds, + normalizeReplaySpeed, + replayStartAfterPause, + replayState, + syncReplayButton, + syncReplayCountdownButton, + syncReplaySpeedControl, + setReplaySpeed, + replayClockNow, + pauseMissionReplay, + resumeMissionReplay, + stopMissionReplay, + startMissionReplay, + }; +} diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js new file mode 100644 index 000000000..813b7875f --- /dev/null +++ b/src/layers/launches/selection.js @@ -0,0 +1,239 @@ +import * as Cesium from 'cesium'; +import { MISSION_GLOBE_VIEW_RANGE_M, MISSION_FOCUS_RANGE_M } from './policy.js'; + +export function createSelection({ + state: layerState, + services, + parts, + source, +}) { + const { getKeyholeGeometry } = services.geometry; + + function focusFullGlobe(viewer, duration = 2.4) { + const canvas = viewer?.scene?.canvas; + const height = canvas?.clientHeight || canvas?.height; + const width = canvas?.clientWidth || canvas?.width; + const cartographic = viewer?.camera?.positionCartographic; + const fovy = viewer?.camera?.frustum?.fovy; + if ( + !(width > 0) || + !(height > 0) || + !cartographic || + !Number.isFinite(fovy) || + fovy <= 0 || + fovy >= Math.PI + ) + return; + const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; + const keyholeRadius = getKeyholeGeometry(width, height).radius; + const targetScreenRadius = keyholeRadius * 0.61; + const angularRadius = Math.atan( + (targetScreenRadius / (height * 0.5)) * Math.tan(fovy * 0.5), + ); + const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4); + const altitude = Math.max(earthRadius * 1.55, distance - earthRadius); + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromRadians( + cartographic.longitude, + cartographic.latitude, + altitude, + ), + orientation: { + heading: viewer.camera.heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + duration, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + }); + } + + function entityLaunchId(entity) { + if (!entity?.id || typeof entity.id !== 'string') return null; + const match = entity.id.match(/^rocket-[^:]+:([^:]+)/); + return match?.[1] || null; + } + + function setSelectedMission(launchId, isolate = true) { + if (launchId) parts.panel.clearMissionRosterHover(); + if ( + layerState._replayCameraLaunchId && + layerState._replayCameraLaunchId !== launchId + ) + parts.replay.stopMissionReplay(); + if ( + layerState._missionZoomAnchorId && + layerState._missionZoomAnchorId !== launchId + ) + stopMissionZoomAnchor(); + layerState._selectedLaunchId = launchId; + layerState._explicitSelection = Boolean(launchId && isolate); + if (launchId) layerState._animationStarts.set(launchId, Date.now()); + if (!launchId && layerState._viewer) { + stopMissionZoomAnchor(); + layerState._viewer.selectedEntity = undefined; + } + if (!layerState._dataSource) return; + for (const entity of layerState._dataSource.entities.values) { + const relatedId = entityLaunchId(entity); + entity.show = launchId + ? relatedId === launchId + : entity.id.startsWith('rocket-launch:'); + } + parts.orbitRendering.syncMissionOrbitPrimitiveVisibility(); + parts.overlays.syncMissionOverlayEntries(); + parts.panel.renderMissionPanel(); + } + + function stopMissionZoomAnchor() { + if (layerState._missionZoomAnchorRemover) + layerState._missionZoomAnchorRemover(); + layerState._missionZoomAnchorRemover = null; + layerState._missionZoomAnchorId = null; + if (layerState._viewer?.camera && !layerState._replayCameraLaunchId) { + layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + } + } + + function startMissionZoomAnchor(launch, initialRange) { + if (!layerState._viewer || !launch || layerState._replayCameraLaunchId) + return; + stopMissionZoomAnchor(); + const target = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); + const range = Math.max( + 2000, + Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M, + ); + layerState._missionZoomAnchorId = launch.id; + layerState._viewer.camera.lookAt( + target, + new Cesium.HeadingPitchRange( + 0, + parts.camera.missionZoomPitch(range), + range, + ), + ); + } + + function focusMission(launch) { + if (!layerState._viewer || !launch) return; + stopMissionZoomAnchor(); + layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( + `rocket-launch:${launch.id}`, + ); + const orbitEntity = layerState._dataSource.entities.getById( + `rocket-orbit:${launch.id}`, + ); + const orbitPositions = orbitEntity?.polyline?.positions?.getValue( + Cesium.JulianDate.now(), + ); + const launchPosition = Cesium.Cartesian3.fromDegrees( + launch.lon, + launch.lat, + ); + let range = 18000000; + if (orbitPositions?.length > 1) { + const canvas = layerState._viewer.scene.canvas; + const fovy = + layerState._viewer.camera.frustum?.fovy || Cesium.Math.toRadians(60); + const aspect = Math.max( + 0.1, + (canvas.clientWidth || canvas.width) / + Math.max(1, canvas.clientHeight || canvas.height), + ); + const fovx = 2 * Math.atan(Math.tan(fovy * 0.5) * aspect); + const paddedHalfAngle = Math.min(fovy, fovx) * 0.5 * 0.62; + const orbitExtent = orbitPositions.reduce( + (largest, point) => + Math.max(largest, Cesium.Cartesian3.distance(launchPosition, point)), + 0, + ); + range = Math.max( + orbitExtent / Math.max(Math.sin(paddedHalfAngle), 0.1), + 18000000, + ); + } + const targetNormal = Cesium.Cartesian3.normalize( + launchPosition, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + launchPosition, + Cesium.Cartesian3.multiplyByScalar( + targetNormal, + range, + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const direction = Cesium.Cartesian3.negate( + targetNormal, + new Cesium.Cartesian3(), + ); + let right = Cesium.Cartesian3.cross( + direction, + Cesium.Cartesian3.UNIT_Z, + new Cesium.Cartesian3(), + ); + if (Cesium.Cartesian3.magnitudeSquared(right) < 1e-8) { + right = Cesium.Cartesian3.cross( + direction, + Cesium.Cartesian3.UNIT_Y, + right, + ); + } + Cesium.Cartesian3.normalize(right, right); + const up = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + layerState._viewer.camera.flyTo({ + destination, + orientation: { direction, up }, + duration: 1.1, + complete: () => { + if (layerState._selectedLaunchId === launch.id) + startMissionZoomAnchor(launch, range); + }, + }); + } + + function focusLaunchSite(launch) { + if (!layerState._viewer || !launch) return; + parts.replay.stopMissionReplay(); + stopMissionZoomAnchor(); + const launchPosition = Cesium.Cartesian3.fromDegrees( + launch.lon, + launch.lat, + ); + layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( + `rocket-launch:${launch.id}`, + ); + layerState._viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere(launchPosition, 0), + { + offset: new Cesium.HeadingPitchRange( + 0, + Cesium.Math.toRadians(-42), + MISSION_FOCUS_RANGE_M, + ), + duration: 1.4, + easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete: () => { + if (layerState._selectedLaunchId === launch.id) { + startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M); + } + }, + }, + ); + } + return { + focusFullGlobe, + entityLaunchId, + setSelectedMission, + stopMissionZoomAnchor, + startMissionZoomAnchor, + focusMission, + focusLaunchSite, + }; +} diff --git a/src/layers/launches/source.js b/src/layers/launches/source.js new file mode 100644 index 000000000..39a49e36c --- /dev/null +++ b/src/layers/launches/source.js @@ -0,0 +1,25 @@ +/** Read launch records and their optional active-orbit catalog with explicit cancellation. */ +export function createLaunchSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getLaunches({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/launches', { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(payload) && !Array.isArray(payload?.results)) + throw new Error('Malformed launch snapshot'); + return payload; + }, + async getActiveTle({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/celestrak/active', { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await response.text(); + signal?.throwIfAborted(); + return text; + }, + }; +} diff --git a/src/layers/launches/source.test.mjs b/src/layers/launches/source.test.mjs new file mode 100644 index 000000000..916ad291e --- /dev/null +++ b/src/layers/launches/source.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLaunchSource } from './source.js'; +import { createRocketLaunchesLayer } from './index.js'; + +test('launch sources preserve last-good eligibility by rejecting malformed snapshots', async () => { + for (const payload of [{}, { results: null }]) { + const source = createLaunchSource({ + fetchImpl: async () => new Response(JSON.stringify(payload)), + }); + await assert.rejects(source.getLaunches(), /Malformed launch snapshot/); + } +}); + +test('launch and active-orbit responses reject cancellation during parsing', async () => { + for (const method of ['getLaunches', 'getActiveTle']) { + const controller = new AbortController(); + const source = createLaunchSource({ + fetchImpl: async () => ({ + ok: true, + async json() { + controller.abort(); + return { results: [] }; + }, + async text() { + controller.abort(); + return 'late orbit'; + }, + }), + }); + await assert.rejects(source[method]({ signal: controller.signal }), { + name: 'AbortError', + }); + } +}); + +test('launch factories construct independently without starting a scene or source request', () => { + const source = { + getLaunches() { + assert.fail('construction fetched launches'); + }, + getActiveTle() { + assert.fail('construction fetched orbits'); + }, + }; + const services = { satellites: {}, geometry: {}, overlays: {}, render: {} }; + const first = createRocketLaunchesLayer({ source, services }); + const second = createRocketLaunchesLayer({ source, services }); + first._setSelectedRocketMissionForTest('first'); + assert.notEqual(first, second); + assert.equal(first.getStats().count, 0); + assert.equal(second.getStats().count, 0); +}); diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js new file mode 100644 index 000000000..0f0194221 --- /dev/null +++ b/src/layers/launches/state.js @@ -0,0 +1,138 @@ +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._dataSource = null; + + state._count = 0; + + state._lastUpdate = null; + + state._lastError = null; + + state._orbitMatches = 0; + + state._clickHandler = null; + + state._moveHandler = null; + + state._viewer = null; + + state._declutterHandler = null; + + state._dataManager = null; + + state._retryTimer = null; + + state._postTleRetryCount = 0; + + state._updatePromise = null; + + state._updatePromiseToken = 0; + + state._updateDirty = false; + + state._lifecycleToken = 0; + + state._enabled = false; + + state._selectedLaunchId = null; + + state._explicitSelection = false; + + state._launches = []; + + state._missionPanel = null; + + state._missionRoster = null; + + state._missionRosterHoverTimer = null; + + state._hoveredRosterLaunchId = null; + + state._missionRosterPreviewOwnership = null; + + state._missionHoverReticleImage = null; + + state._replayVehicleOverlay = null; + + state._replayVehicleOverlayText = ''; + + state._animationStarts = new Map(); + + state._satelliteTelemetry = new Map(); + + state._lastPanelTelemetryMs = 0; + + state._lastMissionRingRotationMs = 0; + + state._activeTleText = null; + + state._activeTlePromise = null; + + state._activeTlePromiseToken = 0; + + state._renderedTleText = null; + + state._focusAfterActiveLookup = false; + + state._satelliteStateBeforeMission = null; + + state._satelliteActivationPromise = null; + + state._replayCameraRemover = null; + + state._replayCameraLaunchId = null; + + state._replayCameraToken = 0; + + state._replayPaused = false; + + state._replayPausedAtMs = null; + + state._replaySpeed = 1; + + state._replayTracks = new Map(); + + state._missionOverlayRecords = new Map(); + + state._missionOverlayHost = state.DEFAULT_OVERLAY_HOST; + + state._selectedMissionOverlayTimeText = null; + + state._missionZoomAnchorRemover = null; + + state._missionZoomAnchorId = null; + + state._launchPadZonePrimitive = null; + + state._launchPadZoneRemover = null; + + state._launchPadZoneLaunchId = null; + + state._missionOrbitPrimitives = new Map(); + + state._declutterTime = new Cesium.JulianDate(); + + state._declutterOccluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, + new Cesium.Cartesian3(), + ); + + state._missionRingDate = new Date(0); + + state._missionOrbitPatternRegistered = false; + + state._pathDistanceCache = new WeakMap(); + state._sourceController = new AbortController(); + return state; +} diff --git a/src/layers/launches/testing.js b/src/layers/launches/testing.js new file mode 100644 index 000000000..ad14633ac --- /dev/null +++ b/src/layers/launches/testing.js @@ -0,0 +1,19 @@ +export function createTesting({ state: layerState, services, parts, source }) { + /** Test seam for real layer lifecycle coverage with a recording host. */ + + function _setRocketMissionOverlayHostForTest(host = null) { + layerState._missionOverlayHost = host + ? { ...layerState.DEFAULT_OVERLAY_HOST, ...host } + : layerState.DEFAULT_OVERLAY_HOST; + } + + /** Test seam that exercises the real selection/deselection path. */ + + function _setSelectedRocketMissionForTest(launchId = null) { + parts.selection.setSelectedMission(launchId, Boolean(launchId)); + } + return { + _setRocketMissionOverlayHostForTest, + _setSelectedRocketMissionForTest, + }; +} diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js new file mode 100644 index 000000000..25eaf9b2c --- /dev/null +++ b/src/layers/satellites/catalog.js @@ -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, + }; +} diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js new file mode 100644 index 000000000..f4910f754 --- /dev/null +++ b/src/layers/satellites/controls.js @@ -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} 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, legend: Array }} 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, + }; +} diff --git a/src/layers/satellites/index.js b/src/layers/satellites/index.js new file mode 100644 index 000000000..7bec672c0 --- /dev/null +++ b/src/layers/satellites/index.js @@ -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'; diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js new file mode 100644 index 000000000..7fb36bb00 --- /dev/null +++ b/src/layers/satellites/ingestion.js @@ -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 }; +} diff --git a/src/layers/satellites/interaction.js b/src/layers/satellites/interaction.js new file mode 100644 index 000000000..5944beaa5 --- /dev/null +++ b/src/layers/satellites/interaction.js @@ -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 }; +} diff --git a/src/layers/satellites/labels.js b/src/layers/satellites/labels.js new file mode 100644 index 000000000..95169be35 --- /dev/null +++ b/src/layers/satellites/labels.js @@ -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, + }; +} diff --git a/src/layers/satellites/lifecycle.js b/src/layers/satellites/lifecycle.js new file mode 100644 index 000000000..7c0d36670 --- /dev/null +++ b/src/layers/satellites/lifecycle.js @@ -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 }; +} diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js new file mode 100644 index 000000000..943e5af14 --- /dev/null +++ b/src/layers/satellites/orbits.js @@ -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, + }; +} diff --git a/src/layers/satellites/policy.js b/src/layers/satellites/policy.js new file mode 100644 index 000000000..656374fc6 --- /dev/null +++ b/src/layers/satellites/policy.js @@ -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; diff --git a/src/layers/satellites/rendering.js b/src/layers/satellites/rendering.js new file mode 100644 index 000000000..08e4396bc --- /dev/null +++ b/src/layers/satellites/rendering.js @@ -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, + }; +} diff --git a/src/layers/satellites/source.js b/src/layers/satellites/source.js new file mode 100644 index 000000000..8bcab7b02 --- /dev/null +++ b/src/layers/satellites/source.js @@ -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 }; + }, + }; +} diff --git a/src/layers/satellites/source.test.mjs b/src/layers/satellites/source.test.mjs new file mode 100644 index 000000000..a44ad565b --- /dev/null +++ b/src/layers/satellites/source.test.mjs @@ -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()); +}); diff --git a/src/layers/satellites/state.js b/src/layers/satellites/state.js new file mode 100644 index 000000000..106802ba6 --- /dev/null +++ b/src/layers/satellites/state.js @@ -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} 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; +} diff --git a/src/layers/satellites/testing.js b/src/layers/satellites/testing.js new file mode 100644 index 000000000..e0f394a50 --- /dev/null +++ b/src/layers/satellites/testing.js @@ -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, + }; +} diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js new file mode 100644 index 000000000..7bdb1d29f --- /dev/null +++ b/src/layers/satellites/tracking.js @@ -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} 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, + }; +} diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index d401d51d7..c43afd80b 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,3 +1,4 @@ +import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readFileSync as readRadioSource } from 'node:fs'; const radioBindings = readRadioSource(new URL('./ui/radioBindings.js', import.meta.url), 'utf8'); @@ -11,7 +12,7 @@ import test from 'node:test'; const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const radio = readFileSync(new URL('./data/radio.js', import.meta.url), 'utf8'); -const rocketLaunches = readFileSync(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8'); +const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8'); const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8'); const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n'); const css = readStylesheet(new URL('../style.css', import.meta.url)); diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index 57a1a2069..b31b9f332 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -11,6 +11,8 @@ export function readLayerSource(file) { 'militaryFlights.js', 'aisLiveVessels.js', 'firmsHeatmap.js', + 'satellites.js', + 'rocketLaunches.js', ].includes(basename(path)) ) return readFileSync(path, 'utf8'); @@ -22,7 +24,11 @@ export function readLayerSource(file) { ? '../layers/military' : basename(path) === 'firmsHeatmap.js' ? '../layers/firms' - : '../layers/vessels', + : basename(path) === 'satellites.js' + ? '../layers/satellites' + : basename(path) === 'rocketLaunches.js' + ? '../layers/launches' + : '../layers/vessels', ); return readdirSync(directory) .filter((name) => name.endsWith('.js'))