mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(space): separate satellite and mission layer components
This commit is contained in:
1 parent
61db945b21
commit
ce0568983a
44 files changed
+8767
-6095
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
+79
-3697
File diff suppressed because it is too large.
Load diff
+51
-2394
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
+35
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<object>}
|
||||
*/
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="mission-replay-flight-symbol">
|
||||
<svg class="mission-replay-rocket" viewBox="0 0 48 72" aria-hidden="true">
|
||||
<path class="mission-replay-rocket-body" d="M24 5C16 14 14 27 15 45L9 54L18 51L24 58L30 51L39 54L33 45C34 27 32 14 24 5Z"></path>
|
||||
<circle class="mission-replay-rocket-port" cx="24" cy="29" r="3.4"></circle>
|
||||
</svg>
|
||||
<svg class="mission-replay-thrust" viewBox="0 0 72 72" aria-hidden="true">
|
||||
<ellipse style="--thrust-index:0" cx="36" cy="7" rx="5" ry="1.8"></ellipse>
|
||||
<ellipse style="--thrust-index:1" cx="36" cy="15" rx="8" ry="2.4"></ellipse>
|
||||
<ellipse style="--thrust-index:2" cx="36" cy="24" rx="11" ry="3"></ellipse>
|
||||
<ellipse style="--thrust-index:3" cx="36" cy="35" rx="15" ry="3.8"></ellipse>
|
||||
<ellipse style="--thrust-index:4" cx="36" cy="48" rx="20" ry="4.7"></ellipse>
|
||||
<ellipse style="--thrust-index:5" cx="36" cy="63" rx="26" ry="5.8"></ellipse>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mission-replay-orbit-dot" aria-hidden="true"></div>
|
||||
<div class="mission-replay-overlay-callout">
|
||||
<strong data-replay-overlay-title></strong>
|
||||
<span data-replay-overlay-detail></span>
|
||||
</div>`;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 `<tr><td colspan="${columns}" class="mission-table-empty">${emptyText}</td></tr>`;
|
||||
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 `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || 'UNSPECIFIED')}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || 'UNAVAILABLE')}</td></tr>`;
|
||||
})
|
||||
: [];
|
||||
if (launch.payloads.length > 5) {
|
||||
payloadRows.push(
|
||||
`<tr><td colspan="3" class="mission-table-empty">+${launch.payloads.length - 5} additional payload records</td></tr>`,
|
||||
);
|
||||
}
|
||||
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 `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
|
||||
});
|
||||
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 =
|
||||
'<div class="space-mission-roster-empty">NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW</div>';
|
||||
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 `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="Select ${escapeMissionText(label)}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
})
|
||||
.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 = `<div class="space-mission-view-header"><span>SELECTED SPACE MISSION</span><button type="button" data-mission-close title="Show all missions" aria-label="Deselect mission">×</button></div><div class="space-mission-detail"><strong data-mission-title>MISSION</strong><span data-mission-field data-mission-provider></span><span data-mission-field>STATUS · <b data-mission-status></b></span><span data-mission-field>LAUNCH SITE · <b data-mission-site></b></span><span data-mission-field>LAUNCH TIME · <b data-mission-time></b></span><span data-mission-field>ORBIT · <b data-mission-orbit></b></span><span>ASCENT PATH · <b data-mission-ascent-source></b></span><span data-mission-field>CURRENT DISTANCE FROM EARTH · <b data-mission-distance></b></span><span data-mission-field>SATELLITE SPEED · <b data-mission-speed></b></span></div><section class="mission-data-section"><h4>PAYLOAD</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>NAME</th><th>TYPE</th><th>DESTINATION</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>STAGE / RE-ENTRY / RECOVERY</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>STAGE</th><th>STATUS</th><th>FINAL POSITION</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">REPLAY SPEED</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="Replay speed multiplier"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>FOCUS</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">REPLAY ASCENT</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="Previous mission"><span aria-hidden="true">‹</span> PREV</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="Next mission">NEXT <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>SHOW ALL / DESELECT</button>`;
|
||||
layerState._missionPanel
|
||||
.querySelector('.mission-action-row')
|
||||
.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
|
||||
<button type="button" data-mission-replay-toggle title="Pause replay" aria-label="Pause replay">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="Cancel replay" aria-label="Cancel replay"><span aria-hidden="true">×</span></button>
|
||||
</div>`,
|
||||
);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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<object>} 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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
+562
@@ -0,0 +1,562 @@
|
||||
import {
|
||||
tallySatelliteClasses,
|
||||
satelliteClassLabel,
|
||||
satelliteClassLegend,
|
||||
} from '../../data/satelliteClass.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const { isExplicitLayerStateOrigin } = services.layerState;
|
||||
|
||||
/**
|
||||
* Resolve the canonical point style for a satellite.
|
||||
* @param {number} noradId NORAD catalog number.
|
||||
* @param {string|undefined} group Catalog group tag (see CATALOG_GROUPS / 'dense').
|
||||
* @returns {{ pixelSize: number, color: Cesium.Color, outlineColor: Cesium.Color, outlineWidth: number }}
|
||||
*/
|
||||
|
||||
function _pointStyleFor(noradId, group) {
|
||||
if (noradId === ISS_NORAD) return POINT_STYLES.iss;
|
||||
return POINT_STYLES[group] || POINT_STYLES.visual;
|
||||
}
|
||||
|
||||
/** Tell the manager to re-render this layer's row (chip state / legend counts). */
|
||||
|
||||
function _notifyRowControls() {
|
||||
try {
|
||||
layerState._rowControlsListener?.();
|
||||
} catch (error) {
|
||||
console.warn('[Data:Satellites] row-controls listener failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-class tally for the row legend, cached against the catalog revision.
|
||||
* Without the cache this scans ~10.7k entries in dense mode on every panel
|
||||
* refresh, and any layer polling on the 1s stats timer refreshes the panel.
|
||||
* @returns {Record<string, number>} Class key → count.
|
||||
*/
|
||||
|
||||
function _classTally() {
|
||||
if (
|
||||
layerState._classTallyCache.counts &&
|
||||
layerState._classTallyCache.revision === layerState._catalogRevision
|
||||
) {
|
||||
return layerState._classTallyCache.counts;
|
||||
}
|
||||
const entries = [];
|
||||
// Pass the ISS flag, not just the group: during a stations-feed outage the
|
||||
// ISS is ingested as `visual`, and the legend must file it exactly where its
|
||||
// card does (STATION) rather than letting STATION vanish from the legend.
|
||||
for (const [noradId, sat] of layerState._catalog) {
|
||||
entries.push({ group: sat.group, isIss: noradId === ISS_NORAD });
|
||||
}
|
||||
const counts = tallySatelliteClasses(entries);
|
||||
layerState._classTallyCache = {
|
||||
revision: layerState._catalogRevision,
|
||||
counts,
|
||||
};
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Satellite preferences may be restored while the layer is disabled (for
|
||||
* example, when Space Missions releases its dependency). Preferences must not
|
||||
* revive render primitives until the layer is explicitly enabled again.
|
||||
* @param {boolean} layerEnabled Whether the Satellite layer is enabled.
|
||||
* @param {boolean} requestedVisible Whether the current presentation requests visibility.
|
||||
* @returns {boolean} Whether a primitive should be visible now.
|
||||
*/
|
||||
|
||||
function satelliteVisualsVisible(layerEnabled, requestedVisible) {
|
||||
return Boolean(layerEnabled) && Boolean(requestedVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a valid catalog request represents a real mode transition.
|
||||
* Reapplying an already-active mode must not restart the dense TLE load.
|
||||
* @param {'core'|'dense'} currentCatalog Current catalog mode.
|
||||
* @param {string|undefined} requestedCatalog Requested catalog mode.
|
||||
* @returns {boolean} Whether the catalog mode should change.
|
||||
*/
|
||||
|
||||
function satelliteCatalogModeChanged(currentCatalog, requestedCatalog) {
|
||||
return (
|
||||
(requestedCatalog === 'core' || requestedCatalog === 'dense') &&
|
||||
requestedCatalog !== currentCatalog
|
||||
);
|
||||
}
|
||||
const methods = {
|
||||
id: 'satellites',
|
||||
|
||||
name: 'Satellites',
|
||||
|
||||
icon: '🛰️',
|
||||
|
||||
source: 'CelesTrak',
|
||||
|
||||
updateInterval: 0,
|
||||
// We use preRender for real-time updates, not interval polling
|
||||
refreshInterval: 5 * 60 * 1000,
|
||||
|
||||
getDetectableObjects(options = {}) {
|
||||
if (!layerState._pointCollection || !layerState._pointCollection.show)
|
||||
return [];
|
||||
// Dense extras are points-only: excluded from the detection overlay.
|
||||
const eligibleCount = Math.max(
|
||||
1,
|
||||
layerState._points.size - layerState._denseIds.length,
|
||||
);
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
? Math.max(1, Math.floor(options.maxCount))
|
||||
: eligibleCount;
|
||||
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||||
const stride = Math.max(1, Math.ceil(eligibleCount / maxCount));
|
||||
const start = seed % stride;
|
||||
|
||||
const result = [];
|
||||
let idx = 0;
|
||||
for (const [noradId, point] of layerState._points) {
|
||||
if (
|
||||
layerState._denseIds.length > 0 &&
|
||||
layerState._catalog.get(noradId)?.group === 'dense'
|
||||
)
|
||||
continue;
|
||||
const shouldTake = (idx - start) % stride === 0;
|
||||
idx++;
|
||||
if (!shouldTake) continue;
|
||||
if (!point.position) continue;
|
||||
const isTracked = noradId === layerState._trackedNorad;
|
||||
// A docked companion sits at the tracked subject's own position, so its
|
||||
// mark and label would stack underneath the tracked card. It is listed on
|
||||
// that card instead. Only members of the tracked cluster are affected —
|
||||
// unrelated nearby satellites are never suppressed.
|
||||
if (!isTracked && layerState._dockedCompanions.has(noradId)) continue;
|
||||
const cat = layerState._catalog.get(noradId);
|
||||
let object = layerState._detectionObjects.get(noradId);
|
||||
if (!object) {
|
||||
object = {
|
||||
sourceId: noradId,
|
||||
id: cat?.name || `SAT-${noradId}`,
|
||||
type: 'SAT',
|
||||
// Human class ("NAV · GPS"), not the raw CelesTrak tag ("GPS-OPS").
|
||||
// The detection canvas composites ABOVE the post-FX chain, so this
|
||||
// is how class survives NVG/FLIR once the dot colors are collapsed.
|
||||
klass: satelliteClassLabel(cat?.group, {
|
||||
isIss: noradId === ISS_NORAD,
|
||||
}),
|
||||
};
|
||||
layerState._detectionObjects.set(noradId, object);
|
||||
}
|
||||
object.position = point.position;
|
||||
object.skipLabel = isTracked;
|
||||
result.push(object);
|
||||
if (result.length >= maxCount) break;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find a satellite by exact NORAD id (numeric string) or case-insensitive
|
||||
* name substring. Position is freshly propagated via SGP4.
|
||||
* @param {string|number} query NORAD id or partial name.
|
||||
* @returns {{ noradId: number, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }|null}
|
||||
*/
|
||||
findByQuery(query) {
|
||||
if (
|
||||
query === null ||
|
||||
query === undefined ||
|
||||
!layerState._catalog ||
|
||||
layerState._catalog.size === 0
|
||||
)
|
||||
return null;
|
||||
const q = String(query).trim();
|
||||
if (!q) return null;
|
||||
|
||||
let noradId = null;
|
||||
if (/^\d+$/.test(q) && layerState._catalog.has(Number(q))) {
|
||||
noradId = Number(q);
|
||||
} else {
|
||||
const lower = q.toLowerCase();
|
||||
for (const [id, sat] of layerState._catalog) {
|
||||
if (sat.name.toLowerCase().includes(lower)) {
|
||||
noradId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (noradId === null) return null;
|
||||
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
const pos = parts.orbits.propagatePosition(sat.satrec, new Date());
|
||||
if (!pos) return null;
|
||||
|
||||
return {
|
||||
noradId,
|
||||
name: sat.name.trim(),
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
),
|
||||
latitude: pos.latitude,
|
||||
longitude: pos.longitude,
|
||||
altitudeM: pos.altitude,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get positions of currently rendered satellites from per-point state
|
||||
* (no SGP4 re-propagation).
|
||||
* @param {number} [maxCount=300] Maximum entries to return.
|
||||
* @returns {Array<{ id: number, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number }>}
|
||||
*/
|
||||
getAllPositions(maxCount = 300) {
|
||||
const result = [];
|
||||
if (!layerState._points || layerState._points.size === 0) return result;
|
||||
const cap =
|
||||
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 300;
|
||||
|
||||
for (const [noradId, point] of layerState._points) {
|
||||
if (result.length >= cap) break;
|
||||
if (!point.position) continue;
|
||||
// Dense extras are points-only — keep voice/framing lists to the core catalog.
|
||||
if (
|
||||
layerState._denseIds.length > 0 &&
|
||||
layerState._catalog.get(noradId)?.group === 'dense'
|
||||
)
|
||||
continue;
|
||||
const carto = Cesium.Cartographic.fromCartesian(point.position);
|
||||
if (!carto) continue;
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
result.push({
|
||||
id: noradId,
|
||||
label: sat ? sat.name.trim() : String(noradId),
|
||||
position: point.position,
|
||||
latitude: Cesium.Math.toDegrees(carto.latitude),
|
||||
longitude: Cesium.Math.toDegrees(carto.longitude),
|
||||
altitudeM: carto.height,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Track a satellite by NORAD id (camera follow + orbit path + highlight),
|
||||
* same path as clicking its point.
|
||||
* @param {string|number} noradId NORAD catalog number.
|
||||
* @returns {boolean} True if tracking started.
|
||||
*/
|
||||
trackById(noradId, { origin = 'programmatic' } = {}) {
|
||||
const id = Number(noradId);
|
||||
if (
|
||||
!Number.isFinite(id) ||
|
||||
!layerState._viewer ||
|
||||
!layerState._catalog.has(id) ||
|
||||
!layerState._points.has(id)
|
||||
)
|
||||
return false;
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
parts.tracking._trackSatellite(id, { origin });
|
||||
return layerState._trackedNorad === id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve a shared Follow target only after the applicable CelesTrak
|
||||
* catalog has settled. A partial catalog can prove presence, never absence.
|
||||
*/
|
||||
async resolveTrackingRestoreTarget(
|
||||
noradId,
|
||||
{ signal = null, origin = 'share-restore' } = {},
|
||||
) {
|
||||
if (signal?.aborted)
|
||||
return {
|
||||
status: 'cancelled',
|
||||
reason: String(signal.reason || 'aborted'),
|
||||
};
|
||||
const id = parts.tracking._normalizeTrackedNorad(noradId);
|
||||
if (id === null) return { status: 'missing', reason: 'invalid-target' };
|
||||
const outcome = layerState._lastTrackingRefreshOutcome;
|
||||
const found = () =>
|
||||
layerState._catalog.has(id) && layerState._points.has(id);
|
||||
const follow = () => {
|
||||
if (signal?.aborted)
|
||||
return {
|
||||
status: 'cancelled',
|
||||
reason: String(signal.reason || 'aborted'),
|
||||
};
|
||||
return this.trackById(id, { origin })
|
||||
? { status: 'found', refreshEpoch: outcome.epoch }
|
||||
: {
|
||||
status: 'source-unavailable',
|
||||
reason: 'target-not-renderable',
|
||||
refreshEpoch: outcome.epoch,
|
||||
};
|
||||
};
|
||||
|
||||
if (found()) return follow();
|
||||
if (outcome.status !== 'accepted' && outcome.status !== 'partial') {
|
||||
return {
|
||||
status: 'source-unavailable',
|
||||
reason: 'CelesTrak catalog unavailable',
|
||||
refreshEpoch: outcome.epoch,
|
||||
};
|
||||
}
|
||||
|
||||
if (layerState._params.catalog === 'dense') {
|
||||
const dense = await (layerState._denseLoadPromise ||
|
||||
Promise.resolve({ status: 'source-unavailable' }));
|
||||
if (signal?.aborted)
|
||||
return {
|
||||
status: 'cancelled',
|
||||
reason: String(signal.reason || 'aborted'),
|
||||
};
|
||||
if (layerState._lastTrackingRefreshOutcome.epoch !== outcome.epoch) {
|
||||
return { status: 'superseded', reason: 'newer-catalog-refresh' };
|
||||
}
|
||||
if (found()) return follow();
|
||||
if (dense?.status !== 'ready') {
|
||||
return {
|
||||
status: 'source-unavailable',
|
||||
reason: dense?.reason || 'dense catalog unavailable',
|
||||
refreshEpoch: outcome.epoch,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (outcome.status === 'partial') {
|
||||
return {
|
||||
status: 'source-unavailable',
|
||||
reason: 'partial CelesTrak catalog cannot prove absence',
|
||||
refreshEpoch: outcome.epoch,
|
||||
failedGroups: [...outcome.failedGroups],
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'missing',
|
||||
reason: 'target-absent-from-catalog',
|
||||
refreshEpoch: outcome.epoch,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop tracking the currently tracked satellite (no-op if none).
|
||||
* @returns {boolean} Always true.
|
||||
*/
|
||||
stopTracking({ origin = 'programmatic' } = {}) {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
parts.tracking._clearTracking(false, { origin });
|
||||
return true;
|
||||
},
|
||||
|
||||
cancelPendingTrackingRestore() {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get info about the currently tracked satellite.
|
||||
* @returns {{ noradId: number, name: string, latitude: number, longitude: number, altitudeM: number }|null}
|
||||
*/
|
||||
getTrackedInfo() {
|
||||
if (
|
||||
layerState._trackedNorad === null ||
|
||||
!layerState._catalog.has(layerState._trackedNorad)
|
||||
)
|
||||
return null;
|
||||
const sat = layerState._catalog.get(layerState._trackedNorad);
|
||||
// Per-frame cache (WS-D2) — same epoch as the dot/label/camera this frame.
|
||||
const pos = parts.tracking._getTrackedFramePosition();
|
||||
if (!pos) return null;
|
||||
return {
|
||||
noradId: layerState._trackedNorad,
|
||||
name: sat.name.trim(),
|
||||
latitude: pos.latitude,
|
||||
longitude: pos.longitude,
|
||||
altitudeM: pos.altitude,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Runtime params (DataLayerManager.setLayerParams path).
|
||||
* catalog: 'core' (default, ~840 sats) | 'dense' (adds the Starlink shell
|
||||
* as points-only extras on a relaxed propagation budget).
|
||||
* @param {{ catalog?: 'core'|'dense', showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params]
|
||||
*/
|
||||
setParams(params = {}, { origin = 'programmatic' } = {}) {
|
||||
if (
|
||||
isExplicitLayerStateOrigin(origin) &&
|
||||
!Object.hasOwn(params, 'selectedSatTrackingId')
|
||||
) {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
}
|
||||
const catalog = params.catalog;
|
||||
if (catalog !== undefined && catalog !== 'core' && catalog !== 'dense')
|
||||
return false;
|
||||
const catalogChanged = satelliteCatalogModeChanged(
|
||||
layerState._params.catalog,
|
||||
catalog,
|
||||
);
|
||||
if (catalogChanged) {
|
||||
layerState._params.catalog = catalog;
|
||||
}
|
||||
if (params.showPoints !== undefined) {
|
||||
layerState._params.showPoints = params.showPoints !== false;
|
||||
if (layerState._pointCollection)
|
||||
layerState._pointCollection.show = satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showPoints,
|
||||
);
|
||||
}
|
||||
if (params.showOrbits !== undefined) {
|
||||
layerState._params.showOrbits = params.showOrbits !== false;
|
||||
for (const path of layerState._orbitPaths.values())
|
||||
path.primitive.show = satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
);
|
||||
parts.labels._syncIssOverlay();
|
||||
}
|
||||
if (catalogChanged && catalog === 'dense') {
|
||||
layerState._denseLoadPromise = parts.catalog._loadDenseCatalog();
|
||||
} else if (catalog === 'core') {
|
||||
if (catalogChanged) parts.catalog._removeDenseCatalog();
|
||||
// Any explicit request for core clears the error, even when the mode did
|
||||
// NOT change: a failed dense load already reverted the param to core, so
|
||||
// a Space Missions restore of an already-core snapshot would otherwise
|
||||
// leave the user staring at a DENSE ✕ they never caused.
|
||||
layerState._denseStatus = 'idle';
|
||||
layerState._denseError = null;
|
||||
}
|
||||
if (catalogChanged)
|
||||
console.log(`[Data:Satellites] Catalog mode: ${catalog}`);
|
||||
if (Object.hasOwn(params, 'selectedSatTrackingId')) {
|
||||
const requested = parts.tracking._normalizeTrackedNorad(
|
||||
params.selectedSatTrackingId,
|
||||
);
|
||||
if (requested === layerState._trackedNorad) {
|
||||
layerState._pendingTrackingRestore = null;
|
||||
} else if (requested === null) {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (layerState._trackedNorad !== null)
|
||||
parts.tracking._clearTracking(false, { origin });
|
||||
} else {
|
||||
const generation = ++layerState._trackingIntentGeneration;
|
||||
layerState._pendingTrackingRestore = {
|
||||
id: requested,
|
||||
generation,
|
||||
origin,
|
||||
};
|
||||
if (layerState._trackedNorad !== null)
|
||||
parts.tracking._clearTracking(false, { origin });
|
||||
parts.tracking._applyPendingTrackingRestore();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/** @returns {{ catalog: string }} Current runtime params. */
|
||||
getParams() {
|
||||
return {
|
||||
catalog: layerState._params.catalog,
|
||||
showPoints: layerState._params.showPoints,
|
||||
showOrbits: layerState._params.showOrbits,
|
||||
selectedSatTrackingId: layerState._trackedNorad,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Layer-row sub-controls (DataLayerManager row-controls contract): the DENSE
|
||||
* catalog chip plus a class legend so the point colors are learnable without
|
||||
* a new panel.
|
||||
*
|
||||
* The chip is stateless — it declares the params to apply and the manager
|
||||
* owns the write, so the Space Missions snapshot/restore path (which drives
|
||||
* the same `catalog` param) stays the single source of truth and the chip
|
||||
* always renders whatever the layer actually has.
|
||||
*
|
||||
* The chip reports the dense LOAD state, not the catalog param: the param
|
||||
* flips synchronously while the Starlink shell takes seconds to arrive and
|
||||
* frequently 502s, so ACTIVE means "dense points are on screen" and nothing
|
||||
* less. The legend tally is cached against the catalog revision.
|
||||
* @returns {{ chips: Array<object>, legend: Array<object> }} Row controls.
|
||||
*/
|
||||
getRowControls() {
|
||||
// A dependency owner (Space Missions) borrows this layer for TLE lookup
|
||||
// with showPoints:false. Nothing is rendered, so a legend would describe an
|
||||
// empty sky and a chip write would be silently reverted by that owner's
|
||||
// restore. Surrender the row rather than lie about it.
|
||||
if (!layerState._params.showPoints) return { chips: [], legend: [] };
|
||||
|
||||
const loading = layerState._denseStatus === 'loading';
|
||||
const failed = layerState._denseStatus === 'failed';
|
||||
const active =
|
||||
layerState._params.catalog === 'dense' &&
|
||||
layerState._denseStatus === 'ready';
|
||||
let title =
|
||||
'Add the full Starlink broadband shell (thousands of extra points)';
|
||||
if (loading) title = 'Loading the Starlink shell…';
|
||||
else if (failed)
|
||||
title = `Starlink ${layerState._denseError || 'load failed'} — click to retry`;
|
||||
else if (active)
|
||||
title =
|
||||
'Showing the full Starlink shell — click for the core catalog only';
|
||||
return {
|
||||
chips: [
|
||||
{
|
||||
id: 'catalog',
|
||||
label: loading ? 'DENSE ···' : failed ? 'DENSE ✕' : 'DENSE',
|
||||
active,
|
||||
busy: loading,
|
||||
disabled: loading,
|
||||
state: loading
|
||||
? 'loading'
|
||||
: failed
|
||||
? 'error'
|
||||
: active
|
||||
? 'active'
|
||||
: 'idle',
|
||||
title,
|
||||
params: { catalog: active ? 'core' : 'dense' },
|
||||
},
|
||||
],
|
||||
legend: satelliteClassLegend(_classTally()),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Install the manager's "row controls changed" callback. The dense load is
|
||||
* asynchronous, so completion and failure have to push a re-render — nothing
|
||||
* else would repaint this row before the 5-minute catalog refresh.
|
||||
* @param {(() => void)|null} listener Callback, or null to detach.
|
||||
*/
|
||||
setRowControlsListener(listener) {
|
||||
layerState._rowControlsListener =
|
||||
typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: layerState._count,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
stale: false,
|
||||
status:
|
||||
layerState._lastError === 'CelesTrak unreachable'
|
||||
? 'unavailable'
|
||||
: layerState._lastError
|
||||
? 'degraded'
|
||||
: 'nominal',
|
||||
error: layerState._lastError,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
_pointStyleFor,
|
||||
_notifyRowControls,
|
||||
_classTally,
|
||||
satelliteVisualsVisible,
|
||||
satelliteCatalogModeChanged,
|
||||
methods,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createState({ services }) {
|
||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
|
||||
services.overlays;
|
||||
const state = {};
|
||||
|
||||
state.DEFAULT_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
});
|
||||
|
||||
state._overlayHost = state.DEFAULT_OVERLAY_HOST;
|
||||
|
||||
// Satellite catalog: { noradId → { name, satrec, group } }
|
||||
|
||||
state._catalog = new Map();
|
||||
|
||||
state._pointCollection = null;
|
||||
|
||||
state._points = new Map();
|
||||
// noradId → point primitive
|
||||
/** Stable lightweight records reused by the detection overlay between updates. */
|
||||
|
||||
state._detectionObjects = new Map();
|
||||
|
||||
// noradId → { primitive, gmstAtBake }
|
||||
// primitive is a one-instance Cesium.Primitive holding the ring baked in ECEF
|
||||
// at gmstAtBake; the preRender tick re-aligns it to current GMST by updating
|
||||
// its modelMatrix (rigid Z-rotation — no geometry rebuild, WS-D1).
|
||||
|
||||
state._orbitPaths = new Map();
|
||||
|
||||
state._count = 0;
|
||||
|
||||
state._lastUpdate = null;
|
||||
|
||||
/** @type {string|null} Surfaced feed error (e.g. CelesTrak outage) for the layer chip. */
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
state._activeUpdateControllers = new Set();
|
||||
|
||||
state._denseLoadController = null;
|
||||
|
||||
state._viewer = null;
|
||||
|
||||
state._preRenderListener = null;
|
||||
|
||||
state._lastPropagation = 0;
|
||||
|
||||
state._lastRingRotation = 0;
|
||||
|
||||
state._lastFocusUpdate = 0;
|
||||
|
||||
/** Points whose animated emphasis remains outside the 1.0 deadband. */
|
||||
|
||||
state._activeFocusCount = 0;
|
||||
|
||||
state._scratchFocusScreen = new Cesium.Cartesian2();
|
||||
|
||||
state._enabled = false;
|
||||
|
||||
// Click-to-track state
|
||||
|
||||
state._trackedNorad = null;
|
||||
|
||||
state._pendingTrackingRestore = null;
|
||||
|
||||
state._trackingIntentGeneration = 0;
|
||||
|
||||
state._trackingRefreshEpoch = 0;
|
||||
|
||||
state._lastTrackingRefreshOutcome = {
|
||||
epoch: 0,
|
||||
status: 'unavailable',
|
||||
failedGroups: [],
|
||||
};
|
||||
|
||||
state._trackedEntity = null;
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
/** @type {Cesium.Event.RemoveCallback|null} trackedEntityChanged listener disposer (cross-layer untrack) */
|
||||
|
||||
state._trackedEntityChangedRemove = null;
|
||||
|
||||
// Runtime params (DataLayerManager.setLayerParams path)
|
||||
|
||||
state._params = { catalog: 'core', showPoints: true, showOrbits: true };
|
||||
// 'core' | 'dense'
|
||||
|
||||
state._denseIds = [];
|
||||
// norad ids of dense extras, round-robin order
|
||||
|
||||
state._denseCursor = 0;
|
||||
// next dense id to re-propagate
|
||||
|
||||
state._denseLoadToken = 0;
|
||||
// invalidates in-flight dense loads on mode flip/reload
|
||||
|
||||
state._denseLoadPromise = null;
|
||||
|
||||
/**
|
||||
* Dense-load lifecycle: 'idle' → 'loading' → 'ready' | 'failed'.
|
||||
* The catalog param flips synchronously but the Starlink shell arrives over
|
||||
* seconds and can fail outright (CelesTrak 502s this feed regularly), so the
|
||||
* row chip reports THIS, not the param. An active chip must mean dense points
|
||||
* are actually on screen.
|
||||
*/
|
||||
|
||||
state._denseStatus = 'idle';
|
||||
|
||||
/** @type {string|null} Why the last dense load failed, for the chip tooltip. */
|
||||
|
||||
state._denseError = null;
|
||||
|
||||
/** Bumped on every bulk catalog mutation; keys the row-legend tally cache. */
|
||||
|
||||
state._catalogRevision = 0;
|
||||
|
||||
state._classTallyCache = { revision: -1, counts: null };
|
||||
|
||||
/** @type {(() => void)|null} Manager callback: "this layer's row controls changed". */
|
||||
|
||||
state._rowControlsListener = null;
|
||||
|
||||
/** @type {Set<number>} NORAD ids co-located with the tracked satellite. */
|
||||
|
||||
state._dockedCompanions = new Set();
|
||||
|
||||
state._lastDockedScanMs = Number.NEGATIVE_INFINITY;
|
||||
|
||||
// Per-frame cache for the tracked satellite (WS-D2): dot, host readout, camera, and
|
||||
// getTrackedInfo all read one SGP4 sample per rendered frame, keyed on
|
||||
// scene.frameState.frameNumber, so they never diverge in epoch.
|
||||
|
||||
state._trackedFrameNumber = -1;
|
||||
|
||||
state._trackedFrameGeo = null;
|
||||
// { longitude, latitude, altitude } or null
|
||||
|
||||
state._trackedFrameCartesian = new Cesium.Cartesian3();
|
||||
|
||||
/** Optional deterministic clock used only by the production-frame test seam. */
|
||||
|
||||
state._trackedFrameNowForTest = null;
|
||||
|
||||
// Scratch variables
|
||||
|
||||
state._scratchCartesian = new Cesium.Cartesian3();
|
||||
|
||||
state._scratchRingRotation = new Cesium.Matrix3();
|
||||
|
||||
/** Update the explicit model only when the rounded altitude line changes. */
|
||||
/** Epoch of the last shared-context refresh for the tracked satellite. */
|
||||
|
||||
state._contextRefreshedAtMs = 0;
|
||||
|
||||
state._lookupTleText = null;
|
||||
|
||||
state._lookupTleEntries = [];
|
||||
return state;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassLabel } from '../../data/satelliteClass.js';
|
||||
import {
|
||||
ISS_NORAD,
|
||||
CONTEXT_REFRESH_INTERVAL_MS,
|
||||
HIGH_ORBIT_ALTITUDE_M,
|
||||
TRACK_VIEW_FROM_HIGH_SCALE,
|
||||
TRACK_VIEW_FROM_LEO,
|
||||
} from './policy.js';
|
||||
|
||||
export function createTracking({ state: layerState, services, parts, source }) {
|
||||
const { clearFocusTarget, publishFocusTargetFromCachedPosition } =
|
||||
services.focus;
|
||||
const {
|
||||
clearTrackedSubjectContext,
|
||||
getContextStore,
|
||||
refreshTrackedSubjectContext,
|
||||
selectTrackedSubjectContext,
|
||||
} = services.context;
|
||||
const { refreshTrackedReadout } = services.readout;
|
||||
|
||||
function _normalizeTrackedNorad(candidate) {
|
||||
const numeric = Number(candidate);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
const rounded = Math.trunc(numeric);
|
||||
return rounded > 0 ? rounded : null;
|
||||
}
|
||||
|
||||
function _emitAwarenessEvent(type, detail) {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
!window.dispatchEvent ||
|
||||
typeof CustomEvent === 'undefined'
|
||||
)
|
||||
return;
|
||||
window.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
}
|
||||
|
||||
function _applyPendingTrackingRestore() {
|
||||
const pending = layerState._pendingTrackingRestore;
|
||||
if (
|
||||
!pending ||
|
||||
pending.generation !== layerState._trackingIntentGeneration ||
|
||||
!layerState._enabled
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
!layerState._viewer ||
|
||||
!layerState._catalog.has(pending.id) ||
|
||||
!layerState._points.has(pending.id)
|
||||
)
|
||||
return false;
|
||||
layerState._pendingTrackingRestore = null;
|
||||
_trackSatellite(pending.id, { origin: pending.origin });
|
||||
return layerState._trackedNorad === pending.id;
|
||||
}
|
||||
|
||||
function _cancelPendingTrackingRestore() {
|
||||
layerState._trackingIntentGeneration += 1;
|
||||
layerState._pendingTrackingRestore = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking the currently followed satellite.
|
||||
* @param {boolean} [skipViewerUntrack=false] - When ANOTHER layer just grabbed
|
||||
* the follow-camera (viewer.trackedEntityChanged), tear down our own state
|
||||
* but do NOT clear viewer.trackedEntity — the new owner controls it now,
|
||||
* and clearing it would yank the camera off their target (mirror of flights).
|
||||
*/
|
||||
|
||||
function _clearTracking(
|
||||
skipViewerUntrack = false,
|
||||
{ origin = 'programmatic' } = {},
|
||||
) {
|
||||
// Untracking dissolves the cluster: every companion returns to its own
|
||||
// ambient label on the next collection.
|
||||
layerState._dockedCompanions = new Set();
|
||||
layerState._lastDockedScanMs = Number.NEGATIVE_INFINITY;
|
||||
if (!layerState._trackedNorad) {
|
||||
clearFocusTarget('satellites');
|
||||
parts.labels._syncIssOverlay();
|
||||
return;
|
||||
}
|
||||
const clearedNorad = layerState._trackedNorad;
|
||||
clearFocusTarget('satellites', layerState._trackedNorad);
|
||||
|
||||
const lastPos = layerState._points.get(layerState._trackedNorad);
|
||||
|
||||
// Re-show the primitive (hidden while the tracked entity rendered the dot)
|
||||
// and restore the original group palette from the shared style table (WS-D3)
|
||||
if (lastPos) {
|
||||
const style = parts.controls._pointStyleFor(
|
||||
layerState._trackedNorad,
|
||||
layerState._catalog.get(layerState._trackedNorad)?.group,
|
||||
);
|
||||
lastPos.show = true;
|
||||
lastPos.pixelSize = style.pixelSize;
|
||||
lastPos.color = style.color;
|
||||
lastPos.outlineColor = style.outlineColor;
|
||||
lastPos.outlineWidth = style.outlineWidth;
|
||||
lastPos.disableDepthTestDistance = 0;
|
||||
}
|
||||
|
||||
// Invalidate the per-frame tracked-position cache (WS-D2)
|
||||
layerState._trackedFrameNumber = -1;
|
||||
layerState._trackedFrameGeo = null;
|
||||
|
||||
// Remove tracked entity and orbit path (unless ISS — keep its path)
|
||||
if (layerState._trackedNorad !== ISS_NORAD) {
|
||||
parts.rendering._hideOrbitPath(layerState._trackedNorad);
|
||||
}
|
||||
if (layerState._viewer && !skipViewerUntrack)
|
||||
layerState._viewer.trackedEntity = undefined;
|
||||
if (layerState._trackedEntity) {
|
||||
layerState._viewer.entities.remove(layerState._trackedEntity);
|
||||
layerState._trackedEntity = null;
|
||||
}
|
||||
layerState._trackedNorad = null;
|
||||
parts.labels._syncIssOverlay();
|
||||
clearTrackedSubjectContext('satellites');
|
||||
layerState._contextRefreshedAtMs = 0;
|
||||
_emitAwarenessEvent('gev:awareness-subject-cleared', {
|
||||
layerId: 'satellites',
|
||||
id: clearedNorad,
|
||||
origin,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tracked satellite's geodetic position, propagated at most once per
|
||||
* rendered frame (WS-D2). All tracked-satellite consumers (entity position
|
||||
* callback → camera, host model, point primitive, getTrackedInfo) share this
|
||||
* single `new Date()` epoch per frame, so they can never diverge by the old
|
||||
* 200ms throttle. The matching ECEF position is left in
|
||||
* `_trackedFrameCartesian`. SGP4 for one satellite per frame is cheap.
|
||||
* @returns {{ longitude: number, latitude: number, altitude: number }|null}
|
||||
*/
|
||||
|
||||
function _getTrackedFramePosition() {
|
||||
if (layerState._trackedNorad === null) return null;
|
||||
const sat = layerState._catalog.get(layerState._trackedNorad);
|
||||
if (!sat) return null;
|
||||
|
||||
const frameNumber =
|
||||
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
|
||||
if (
|
||||
frameNumber === -1 ||
|
||||
frameNumber !== layerState._trackedFrameNumber ||
|
||||
layerState._trackedFrameGeo === null
|
||||
) {
|
||||
const pos = parts.orbits.propagatePosition(
|
||||
sat.satrec,
|
||||
layerState._trackedFrameNowForTest
|
||||
? new Date(layerState._trackedFrameNowForTest())
|
||||
: new Date(),
|
||||
);
|
||||
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
|
||||
layerState._trackedFrameGeo = pos;
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
undefined,
|
||||
layerState._trackedFrameCartesian,
|
||||
);
|
||||
layerState._trackedFrameNumber = frameNumber;
|
||||
// Throttled inside; membership changes are rare, so resync the ISS ambient
|
||||
// gate only when the cluster actually changed.
|
||||
const clusterChanged = parts.labels._refreshDockedCompanions(
|
||||
layerState._trackedFrameNowForTest
|
||||
? layerState._trackedFrameNowForTest()
|
||||
: Date.now(),
|
||||
);
|
||||
_updateTrackedSatelliteLabelModel();
|
||||
// The card and the context slot describe the same satellite — keep them
|
||||
// together so voice never narrates a fix the card has already replaced.
|
||||
_refreshTrackedSubjectContext();
|
||||
if (clusterChanged) parts.labels._syncIssOverlay();
|
||||
const name = sat.name?.trim() || `SAT-${layerState._trackedNorad}`;
|
||||
const altitudeText = `${Math.round(pos.altitude / 1000)} km · NORAD ${layerState._trackedNorad}`;
|
||||
const labelWidthPx =
|
||||
Math.max(name.length, altitudeText.length) * 7.8 + 20;
|
||||
const labelHeightPx = 2 * 13 + 12;
|
||||
const trackedPointDiameterPx = 14 + 4; // point plus its 2 px outline on both sides
|
||||
// Exact SGP4 frame cache only: dot, host readout, camera, and focus rectangle all
|
||||
// share one epoch, avoiding a second propagation phase and its old jitter.
|
||||
publishFocusTargetFromCachedPosition({
|
||||
ownerLayer: 'satellites',
|
||||
id: layerState._trackedNorad,
|
||||
scene: layerState._viewer?.scene,
|
||||
camera: layerState._viewer?.camera,
|
||||
displayPosition: layerState._trackedFrameCartesian,
|
||||
widthPx: Math.max(trackedPointDiameterPx, Math.min(260, labelWidthPx)),
|
||||
// Union of the 14 px point and the two-line label shifted 18 px upward.
|
||||
heightPx: trackedPointDiameterPx + 18 + labelHeightPx,
|
||||
});
|
||||
}
|
||||
return layerState._trackedFrameGeo;
|
||||
}
|
||||
|
||||
/** Cached ECEF display point only; never propagates a fresh SGP4 sample. */
|
||||
|
||||
function _trackedDisplayCached() {
|
||||
return layerState._trackedFrameGeo
|
||||
? layerState._trackedFrameCartesian
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the tracked satellite for the shared context slot the voice tools
|
||||
* read. Values come from the live per-frame propagation, not a selection-time
|
||||
* snapshot, so a long follow never narrates a position the satellite has left.
|
||||
* @param {number} noradId Catalog identity.
|
||||
* @param {{latitude: number, longitude: number, altitude: number}|null} [position]
|
||||
* Explicit position; defaults to the current per-frame propagation.
|
||||
* @returns {object|null} Context metadata, or null when the satellite is gone.
|
||||
*/
|
||||
|
||||
function _contextSubjectMetadata(noradId, position = null) {
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
if (!sat) return null;
|
||||
const pos = position || _getTrackedFramePosition();
|
||||
if (!pos) return null;
|
||||
const name = sat.name?.trim() || `SAT-${noradId}`;
|
||||
const altitudeKm = Number.isFinite(pos.altitude)
|
||||
? Math.round(pos.altitude / 1000)
|
||||
: null;
|
||||
return {
|
||||
id: String(noradId),
|
||||
layerId: 'satellites',
|
||||
layerName: 'Satellites',
|
||||
source: 'CelesTrak',
|
||||
label: name,
|
||||
latitude: pos.latitude,
|
||||
longitude: pos.longitude,
|
||||
// Flat text only: the voice payload compacts properties through a string
|
||||
// cleaner that drops nested objects.
|
||||
properties: {
|
||||
name,
|
||||
operator: '',
|
||||
noradId: String(noradId),
|
||||
class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }),
|
||||
altitude:
|
||||
altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the published subject with a freshly rebuilt catalog.
|
||||
*
|
||||
* A rebuild (dense↔core toggle, TLE refresh) clears and repopulates the
|
||||
* catalog, so the tracked satellite's entry is a NEW object with a new satrec.
|
||||
* A surviving subject is simply re-resolved against it. A subject that is GONE
|
||||
* must release the slot: the per-frame refresh cannot do this itself, because
|
||||
* `_getTrackedFramePosition` returns early once the satellite has no catalog
|
||||
* entry — so without this the record would linger and voice would narrate a
|
||||
* satellite the catalog no longer carries, frozen at its last position.
|
||||
*
|
||||
* Releasing is gated on PROOF. The subject is preserved unless it is absent
|
||||
* from a catalog that is both complete (`accepted`, no failed CelesTrak group)
|
||||
* and applicable (dense settled, when dense is the requested catalog). A
|
||||
* partial refresh, a failed dense load, or an empty catalog is unproven
|
||||
* absence, and unproven absence is not absence — the same honesty rule the
|
||||
* tracking-restore path already applies.
|
||||
* @returns {Promise<void>} Resolves once the applicable catalog has settled.
|
||||
*/
|
||||
|
||||
async function _reconcileTrackedSubjectContext() {
|
||||
const subjectAtStart = layerState._trackedNorad;
|
||||
if (subjectAtStart === null) return;
|
||||
// Dense extras land AFTER the core rebuild resolves. Deciding before they
|
||||
// settle called a dense subject missing and deleted its record; the record
|
||||
// then stayed gone, because a refresh can update an existing record but
|
||||
// cannot recreate one.
|
||||
const denseSettlement =
|
||||
layerState._params.catalog === 'dense'
|
||||
? layerState._denseLoadPromise
|
||||
: null;
|
||||
if (denseSettlement) {
|
||||
try {
|
||||
await denseSettlement;
|
||||
} catch {
|
||||
// A failed dense load proves nothing about the subject; fall through and
|
||||
// let the outcome check below preserve it.
|
||||
}
|
||||
}
|
||||
// The operator may have moved on while we waited.
|
||||
if (layerState._trackedNorad !== subjectAtStart) return;
|
||||
|
||||
const metadata = _contextSubjectMetadata(subjectAtStart);
|
||||
if (metadata) {
|
||||
const store = getContextStore();
|
||||
const key = String(subjectAtStart);
|
||||
if (store.entities.has(key)) {
|
||||
refreshTrackedSubjectContext(metadata);
|
||||
} else if (!store.selectedEntityId) {
|
||||
// The record was dropped while the subject was briefly unresolvable.
|
||||
// Restore it — but only into an EMPTY slot: a satellite reappearing must
|
||||
// never yank the subject away from something the operator selected since.
|
||||
selectTrackedSubjectContext(metadata);
|
||||
}
|
||||
layerState._contextRefreshedAtMs = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// Absence only counts when EVERY catalog that could carry the subject
|
||||
// actually loaded. Unproven absence is not absence — the same honesty rule
|
||||
// the tracking-restore path applies.
|
||||
//
|
||||
// Deliberately NOT scoped to the group the subject was last seen in:
|
||||
// CelesTrak reclassifies satellites between groups, so a subject missing
|
||||
// from its old group may simply have moved to one that failed this refresh.
|
||||
// Believing the old group alone would drop it. Any failed or empty group is
|
||||
// therefore a reason to wait — and `accepted` already means every group
|
||||
// returned entries.
|
||||
if (layerState._catalog.size === 0) return;
|
||||
if (layerState._lastTrackingRefreshOutcome?.status !== 'accepted') return;
|
||||
// Dense is a potential carrier too whenever it was REQUESTED — and the
|
||||
// request is read from `denseSettlement`, captured before the await, not
|
||||
// from `_params.catalog` now. A failed dense load reverts the mode to 'core'
|
||||
// as part of settling (an ACTIVE chip over an empty sky would be a lie), so
|
||||
// by the time we get here the intent that made dense a carrier has been
|
||||
// erased. Re-reading it would skip this guard on exactly the runs that need
|
||||
// it and delete a subject the dense catalog might have carried.
|
||||
if (denseSettlement && layerState._denseStatus !== 'ready') return;
|
||||
clearTrackedSubjectContext('satellites');
|
||||
layerState._contextRefreshedAtMs = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the shared context slot current with the tracked satellite, on the
|
||||
* propagation beat rather than the frame clock.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function _refreshTrackedSubjectContext() {
|
||||
if (layerState._trackedNorad === null) return;
|
||||
const now = Date.now();
|
||||
if (now - layerState._contextRefreshedAtMs < CONTEXT_REFRESH_INTERVAL_MS)
|
||||
return;
|
||||
layerState._contextRefreshedAtMs = now;
|
||||
refreshTrackedSubjectContext(
|
||||
_contextSubjectMetadata(layerState._trackedNorad),
|
||||
);
|
||||
}
|
||||
|
||||
function _updateTrackedSatelliteLabelModel(fallbackAltitudeM = null) {
|
||||
if (!layerState._trackedEntity || layerState._trackedNorad === null) return;
|
||||
const sat = layerState._catalog.get(layerState._trackedNorad);
|
||||
const title = sat?.name?.trim() || `SAT-${layerState._trackedNorad}`;
|
||||
const altitudeM =
|
||||
layerState._trackedFrameGeo?.altitude ?? fallbackAltitudeM;
|
||||
const detail = `${Number.isFinite(altitudeM) ? Math.round(altitudeM / 1000) : '?'} km · NORAD ${layerState._trackedNorad}`;
|
||||
// Class leads the detail block: it is what tells the operator WHAT they are
|
||||
// looking at, and it stays readable under the IR styles that flatten the
|
||||
// dot colors to a single channel (the card is painted above post-FX).
|
||||
const details = [
|
||||
satelliteClassLabel(sat?.group, {
|
||||
isIss: layerState._trackedNorad === ISS_NORAD,
|
||||
}),
|
||||
detail,
|
||||
];
|
||||
// Docked companions are consolidated onto the tracked card as SECONDARY info
|
||||
// instead of competing with it as separate ambient labels. Identities are
|
||||
// preserved: the catalog is untouched and every companion returns to its own
|
||||
// label the moment the cluster is no longer tracked.
|
||||
const companions = parts.labels._dockedCompanionNames();
|
||||
if (companions.length > 0) {
|
||||
const extra = companions.length - 1;
|
||||
details.push(
|
||||
`DOCKED · ${companions[0]}${extra > 0 ? ` · +${extra}` : ''}`,
|
||||
);
|
||||
}
|
||||
const current = layerState._trackedEntity.gevLabelModel;
|
||||
// Compare the WHOLE detail array: comparing only `details[0]` swallowed any
|
||||
// change confined to the companions line, so the card would never republish.
|
||||
const unchanged =
|
||||
current?.title === title &&
|
||||
current?.details?.length === details.length &&
|
||||
details.every((line, index) => current.details[index] === line);
|
||||
if (unchanged) return;
|
||||
layerState._trackedEntity.gevLabelModel = {
|
||||
title,
|
||||
details,
|
||||
accent: '#ffd84d',
|
||||
};
|
||||
refreshTrackedReadout(layerState._trackedEntity);
|
||||
}
|
||||
|
||||
function _trackSatellite(noradId, { origin = 'programmatic' } = {}) {
|
||||
_clearTracking(false, { origin });
|
||||
|
||||
const point = layerState._points.get(noradId);
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
if (!point || !sat) return;
|
||||
|
||||
layerState._trackedNorad = noradId;
|
||||
layerState._trackedFrameNumber = -1;
|
||||
layerState._trackedFrameGeo = null;
|
||||
parts.labels._syncIssOverlay();
|
||||
|
||||
// Hide the primitive — the tracked ENTITY renders the dot below. The
|
||||
// entity must own a point graphic so the Viewer's tracking camera can
|
||||
// resolve a bounding sphere and engage viewFrom (a label-only entity
|
||||
// left the camera stranded; this mirrors the proven flights pattern).
|
||||
point.show = false;
|
||||
|
||||
// Show orbital path
|
||||
parts.rendering._showOrbitPath(noradId, Cesium.Color.YELLOW);
|
||||
|
||||
// Tracked entity position propagates per evaluation through the per-frame
|
||||
// cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame.
|
||||
// Falls back to the point primitive's 1s-throttled position if SGP4 fails.
|
||||
const positionProperty = new Cesium.CallbackProperty(() => {
|
||||
const pos = _getTrackedFramePosition();
|
||||
return pos ? layerState._trackedFrameCartesian : point.position;
|
||||
}, false);
|
||||
|
||||
const name = sat.name.trim();
|
||||
|
||||
// Comfortable tracking landing: ~726 km back for LEO (user-validated
|
||||
// "slightly zoomed out" framing — no stutter, label reads cleanly), scaled
|
||||
// up for MEO/GEO so the camera doesn't land on top of a high-orbit dot.
|
||||
const initialPos = parts.orbits.propagatePosition(sat.satrec, new Date());
|
||||
const viewScale =
|
||||
initialPos && initialPos.altitude > HIGH_ORBIT_ALTITUDE_M
|
||||
? TRACK_VIEW_FROM_HIGH_SCALE
|
||||
: 1;
|
||||
const viewFrom = Cesium.Cartesian3.multiplyByScalar(
|
||||
TRACK_VIEW_FROM_LEO,
|
||||
viewScale,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
|
||||
layerState._trackedEntity = layerState._viewer.entities.add({
|
||||
position: positionProperty,
|
||||
viewFrom,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color: Cesium.Color.YELLOW,
|
||||
outlineColor: Cesium.Color.WHITE,
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
layerState._trackedEntity.gevSelectionOrigin = origin;
|
||||
layerState._trackedEntity.gevTrackedId = `satellites:${noradId}`;
|
||||
layerState._trackedEntity.gevDisplayPosition = _trackedDisplayCached;
|
||||
_updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null);
|
||||
|
||||
_emitAwarenessEvent('gev:awareness-subject-selected', {
|
||||
layerId: 'satellites',
|
||||
id: noradId,
|
||||
label: name,
|
||||
position: Cesium.Cartesian3.clone(point.position),
|
||||
origin,
|
||||
});
|
||||
selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos));
|
||||
layerState._contextRefreshedAtMs = Date.now();
|
||||
|
||||
layerState._viewer.trackedEntity = layerState._trackedEntity;
|
||||
console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`);
|
||||
}
|
||||
return {
|
||||
_normalizeTrackedNorad,
|
||||
_emitAwarenessEvent,
|
||||
_applyPendingTrackingRestore,
|
||||
_cancelPendingTrackingRestore,
|
||||
_clearTracking,
|
||||
_getTrackedFramePosition,
|
||||
_trackedDisplayCached,
|
||||
_contextSubjectMetadata,
|
||||
_reconcileTrackedSubjectContext,
|
||||
_refreshTrackedSubjectContext,
|
||||
_updateTrackedSatelliteLabelModel,
|
||||
_trackSatellite,
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in new issue
Block a user