Files
gods-eye-view/src/layers/satellites/orbits.js
T

328 lines
11 KiB
JavaScript

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,
};
}