291 lines
9.0 KiB
JavaScript
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; },
|
|
};
|
|
}
|