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