Files
planet/frontend/public/earth/js/satellite-propagation.js
rayd1o 58671e7bc3
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.74.4
2026-09-13 10:27:00 +08:00

291 lines
9.0 KiB
JavaScript

import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
export const SATELLITE_POSITION_UPDATE_INTERVAL_MS = 250;
export const EARTH_RADIUS_KM = 6378.137;
export function getSatelliteSampleTimeMs(baseTime, index, count) {
return Math.trunc(baseTime) + (index / count) * 2 * Math.PI * 0.1 * 1000 * 60 * 10;
}
export function createSatellitePropagator(THREE, { twoline2satrec, propagate, eciToEcf, gstime }) {
let satelliteSatrecCache = new WeakMap();
let satelliteRealAltitudeEnabled = true;
const FALLBACK_MIN_MEAN_MOTION = 12;
const FALLBACK_MEAN_MOTION_SPREAD = 4;
const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000;
function computeSatellitePosition(satellite, time) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
gstime(time),
);
} catch (error) {
return null;
}
}
function computeDisplayPositionFromEciPosition(positionEci, siderealTime) {
const earthFixedPosition = convertEciPositionToSceneVector(
positionEci,
siderealTime,
);
const x = earthFixedPosition.x;
const y = earthFixedPosition.y;
const z = earthFixedPosition.z;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
const r = Math.sqrt(
positionEci.x * positionEci.x +
positionEci.y * positionEci.y +
positionEci.z * positionEci.z,
);
if (!Number.isFinite(r) || r <= 0) {
return null;
}
const displayRadius = satelliteRealAltitudeEnabled
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const sceneRadius = Math.sqrt(x * x + y * y + z * z);
if (!Number.isFinite(sceneRadius) || sceneRadius <= 0) {
return null;
}
const scale = displayRadius / sceneRadius;
return new THREE.Vector3(x * scale, y * scale, z * scale);
}
function computeSatelliteInertialOrbitPosition(satellite, time, siderealTime) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
siderealTime,
);
} catch (error) {
return null;
}
}
function convertEciPositionToSceneVector(positionEci, siderealTime) {
const positionEcf = eciToEcf(positionEci, siderealTime);
return new THREE.Vector3(
positionEcf.x,
positionEcf.z,
-positionEcf.y,
);
}
function getCompressedRealAltitudeOffset(radiusKm) {
const altitudeKm = Math.max(0, radiusKm - EARTH_RADIUS_KM);
const clampedAltitudeKm = Math.min(
altitudeKm,
SATELLITE_CONFIG.maxDisplayAltitudeKm,
);
const compressionKm = Math.max(1, SATELLITE_CONFIG.altitudeCompressionKm);
const normalizedAltitude = Math.log1p(clampedAltitudeKm / compressionKm) /
Math.log1p(SATELLITE_CONFIG.maxDisplayAltitudeKm / compressionKm);
return THREE.MathUtils.lerp(
SATELLITE_CONFIG.minRealAltitudeOffset,
SATELLITE_CONFIG.maxRealAltitudeOffset,
THREE.MathUtils.clamp(normalizedAltitude, 0, 1),
);
}
function buildSatrecFromProperties(props, fallbackTime) {
if (props.tle_line1 && props.tle_line2) {
// Prefer source-provided TLE lines so the client does not need to rebuild them.
const satrec = twoline2satrec(props.tle_line1, props.tle_line2);
if (!satrec.error) {
return satrec;
}
}
const tleLines = buildTleLinesFromElements(props, fallbackTime);
if (!tleLines) {
return null;
}
return twoline2satrec(tleLines.line1, tleLines.line2);
}
function getOrBuildSatrec(props, fallbackTime) {
// Properties are immutable within a loaded snapshot. Avoid constructing and
// hashing long TLE strings for every satellite on every position update.
const cacheable = props?.epoch || (props?.tle_line1 && props?.tle_line2);
if (cacheable && satelliteSatrecCache.has(props)) {
return satelliteSatrecCache.get(props);
}
const satrec = buildSatrecFromProperties(props, fallbackTime);
if (cacheable) {
satelliteSatrecCache.set(props, satrec);
}
return satrec;
}
function computeTleChecksum(line) {
let sum = 0;
for (const char of line.slice(0, 68)) {
if (char >= "0" && char <= "9") {
sum += Number(char);
} else if (char === "-") {
sum += 1;
}
}
return String(sum % 10);
}
function buildTleLinesFromElements(props, fallbackTime) {
if (!props?.norad_cat_id) {
return null;
}
const requiredValues = [
props.inclination,
props.raan,
props.eccentricity,
props.arg_of_perigee,
props.mean_anomaly,
props.mean_motion,
];
if (requiredValues.some((value) => value === null || value === undefined)) {
return null;
}
const epochDate =
props.epoch && String(props.epoch).length >= 10
? new Date(props.epoch)
: fallbackTime;
if (Number.isNaN(epochDate.getTime())) {
return null;
}
const epochYear = epochDate.getUTCFullYear() % 100;
const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1));
const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1;
const msOfDay =
epochDate.getUTCHours() * 3600000 +
epochDate.getUTCMinutes() * 60000 +
epochDate.getUTCSeconds() * 1000 +
epochDate.getUTCMilliseconds();
const dayFraction = msOfDay / 86400000;
const epochStr =
String(epochYear).padStart(2, "0") +
String(dayOfYear).padStart(3, "0") +
dayFraction.toFixed(8).slice(1);
const eccentricityDigits = Math.round(Number(props.eccentricity) * 1e7)
.toString()
.padStart(7, "0");
// Keep a local fallback for historical rows that do not have stored TLE lines yet.
const line1Core = `1 ${String(props.norad_cat_id).padStart(5, "0")}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 999`;
const line2Core = `2 ${String(props.norad_cat_id).padStart(5, "0")} ${Number(
props.inclination,
)
.toFixed(4)
.padStart(
8,
)} ${Number(props.raan).toFixed(4).padStart(8)} ${eccentricityDigits} ${Number(
props.arg_of_perigee,
)
.toFixed(4)
.padStart(8)} ${Number(props.mean_anomaly).toFixed(4).padStart(8)} ${Number(
props.mean_motion,
)
.toFixed(8)
.padStart(11)}00000`;
return {
line1: line1Core + computeTleChecksum(line1Core),
line2: line2Core + computeTleChecksum(line2Core),
};
}
function generateFallbackPosition(satellite, index, total, time = new Date()) {
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const noradId = satellite.properties?.norad_cat_id || index;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 0;
const meanAnomaly = satellite.properties?.mean_anomaly || 0;
const hash = String(noradId)
.split("")
.reduce((a, b) => a + b.charCodeAt(0), 0);
const randomOffset = (hash % 1000) / 1000;
const rawMeanMotion = Number(satellite.properties?.mean_motion);
const meanMotion =
Number.isFinite(rawMeanMotion) && rawMeanMotion > 0
? rawMeanMotion
: FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD;
const normalizedIndex = index / total;
const elapsedDays = Number.isFinite(time?.getTime?.())
? time.getTime() / FALLBACK_ORBIT_DAY_MS
: Date.now() / FALLBACK_ORBIT_DAY_MS;
const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2;
const theta =
normalizedIndex * Math.PI * 2 * 10 +
(raan * Math.PI) / 180 +
fallbackPhase;
const phi =
(inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1;
const adjustedPhi = Math.abs(phi % Math.PI);
const adjustedTheta = theta + randomOffset * Math.PI * 2;
const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta);
const y = radius * Math.cos(adjustedPhi);
const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta);
return new THREE.Vector3(x, y, z);
}
return {
computeSatellitePosition,
computeSatelliteInertialOrbitPosition,
generateFallbackPosition,
getOrBuildSatrec,
reset() { satelliteSatrecCache = new WeakMap(); },
setRealAltitudeEnabled(enabled) { satelliteRealAltitudeEnabled = enabled; },
};
}