4ab765b0ee
Replace synthetic orbit calculation (Kepler + spacecraft ID hash) with real
data from pcp-ballistics-service via a new proxy endpoint in pcp-ui-service.
Backend:
- TguPlanningController: add GET /api/tgu-planning/spacecraft/{noradId}/flight-line
that proxies FlightLineDTO[] from BallisticsService for a given time interval
Frontend:
- Fetch FlightLineDTO[] (revolution, time, lat/long, swath boundaries) using
the spacecraft's noradId for the current 24h map window
- Group points by real revolution number to build OrbitPassInfo[] with actual
orbit numbers and UTC times instead of synthetic Kepler-based estimates
- Build ground track MapLines and swath MapPolygons from real coordinates;
swath polygon = outer-left edge forward + outer-right edge reversed
- Enable swath layer in the editor map; draw with teal fill/stroke matching
the track colour
- MapPassSelector now shows real revolution numbers in header and handle labels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
226 lines
6.6 KiB
TypeScript
226 lines
6.6 KiB
TypeScript
import { normalizeLon } from "./mapProjection";
|
|
|
|
const DEG = Math.PI / 180;
|
|
const RAD = 180 / Math.PI;
|
|
const EARTH_RADIUS_KM = 6371;
|
|
const EARTH_MU_KM3_S2 = 398600.4418;
|
|
const SIDEREAL_DAY_SECONDS = 86164;
|
|
const DEFAULT_ALTITUDE_KM = 550;
|
|
const DEFAULT_TARGET_THRESHOLD_DEG = 6.5;
|
|
const TARGET_SCAN_STEP_MS = 20 * 1000;
|
|
const STATION_SCAN_STEP_MS = 30 * 1000;
|
|
const MIN_WINDOW_MS = 40 * 1000;
|
|
|
|
export type GroundPoint = {
|
|
lat: number;
|
|
lon: number;
|
|
};
|
|
|
|
export type GroundStationPoint = GroundPoint & {
|
|
id: string;
|
|
};
|
|
|
|
export type PassOrbit = {
|
|
spacecraftId?: string;
|
|
altitudeKm?: number;
|
|
};
|
|
|
|
export type PassRange = {
|
|
fromMs: number;
|
|
toMs: number;
|
|
};
|
|
|
|
export type PassWindow = {
|
|
startMs: number;
|
|
endMs: number;
|
|
quality: number;
|
|
};
|
|
|
|
export function subPoint(orbit: PassOrbit, timeMs: number): GroundPoint {
|
|
const params = orbitParams(orbit);
|
|
const dtSeconds = timeMs / 1000;
|
|
const argument = params.phaseRad + 2 * Math.PI * (dtSeconds / params.periodSeconds);
|
|
const lat = Math.asin(Math.sin(params.inclinationRad) * Math.sin(argument)) * RAD;
|
|
const relativeLon = Math.atan2(Math.cos(params.inclinationRad) * Math.sin(argument), Math.cos(argument));
|
|
const lon = normalizeLon((params.nodeRad + relativeLon) * RAD - (360 / SIDEREAL_DAY_SECONDS) * dtSeconds);
|
|
|
|
return { lat, lon };
|
|
}
|
|
|
|
export function targetPasses(orbit: PassOrbit, target: GroundPoint, range: PassRange): PassWindow[] {
|
|
return scanWindows(orbit, target, range, DEFAULT_TARGET_THRESHOLD_DEG, TARGET_SCAN_STEP_MS);
|
|
}
|
|
|
|
export function stationPasses(orbit: PassOrbit, station: GroundPoint, range: PassRange, minElevDeg = 5): PassWindow[] {
|
|
const thresholdDeg = accessAngleDeg(orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM, minElevDeg);
|
|
return scanWindows(orbit, station, range, thresholdDeg, STATION_SCAN_STEP_MS);
|
|
}
|
|
|
|
export function nearestStation(point: GroundPoint, stations: GroundStationPoint[]): GroundStationPoint | undefined {
|
|
let bestStation: GroundStationPoint | undefined;
|
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
|
|
for (const station of stations) {
|
|
const distance = greatCircleDistanceDeg(point, station);
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
bestStation = station;
|
|
}
|
|
}
|
|
|
|
return bestStation;
|
|
}
|
|
|
|
export type OrbitPassInfo = {
|
|
index: number;
|
|
startMs: number;
|
|
endMs: number;
|
|
revolution?: number;
|
|
};
|
|
|
|
export function orbitPeriodMs(orbit: PassOrbit): number {
|
|
return orbitParams(orbit).periodSeconds * 1000;
|
|
}
|
|
|
|
export function orbitPassList(orbit: PassOrbit, range: PassRange): OrbitPassInfo[] {
|
|
if (range.toMs <= range.fromMs) return [];
|
|
const periodMs = orbitPeriodMs(orbit);
|
|
const passes: OrbitPassInfo[] = [];
|
|
let t = range.fromMs;
|
|
let index = 1;
|
|
while (t < range.toMs) {
|
|
passes.push({ index, startMs: t, endMs: Math.min(t + periodMs, range.toMs) });
|
|
t += periodMs;
|
|
index++;
|
|
}
|
|
return passes;
|
|
}
|
|
|
|
export function groundTrack(orbit: PassOrbit, range: PassRange, maxPoints = 240): GroundPoint[][] {
|
|
if (range.toMs <= range.fromMs) {
|
|
return [];
|
|
}
|
|
|
|
const spanMs = range.toMs - range.fromMs;
|
|
const stepMs = Math.max(60 * 1000, Math.ceil(spanMs / Math.max(2, maxPoints)));
|
|
const segments: GroundPoint[][] = [];
|
|
let current: GroundPoint[] = [];
|
|
let previousLon: number | undefined;
|
|
|
|
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
|
|
const point = subPoint(orbit, timeMs);
|
|
if (previousLon !== undefined && Math.abs(point.lon - previousLon) > 180) {
|
|
if (current.length > 1) {
|
|
segments.push(current);
|
|
}
|
|
current = [];
|
|
}
|
|
current.push(point);
|
|
previousLon = point.lon;
|
|
}
|
|
|
|
const endPoint = subPoint(orbit, range.toMs);
|
|
if (current.length === 0 || current[current.length - 1].lat !== endPoint.lat || current[current.length - 1].lon !== endPoint.lon) {
|
|
current.push(endPoint);
|
|
}
|
|
if (current.length > 1) {
|
|
segments.push(current);
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
export function greatCircleDistanceDeg(first: GroundPoint, second: GroundPoint): number {
|
|
const firstLat = first.lat * DEG;
|
|
const secondLat = second.lat * DEG;
|
|
const deltaLon = (second.lon - first.lon) * DEG;
|
|
const cosine =
|
|
Math.sin(firstLat) * Math.sin(secondLat) +
|
|
Math.cos(firstLat) * Math.cos(secondLat) * Math.cos(deltaLon);
|
|
|
|
return Math.acos(Math.max(-1, Math.min(1, cosine))) * RAD;
|
|
}
|
|
|
|
export function accessAngleDeg(altitudeKm: number, minElevDeg: number): number {
|
|
const minElevRad = minElevDeg * DEG;
|
|
const ratio = EARTH_RADIUS_KM / (EARTH_RADIUS_KM + altitudeKm);
|
|
return (Math.acos(ratio * Math.cos(minElevRad)) - minElevRad) * RAD;
|
|
}
|
|
|
|
function scanWindows(
|
|
orbit: PassOrbit,
|
|
target: GroundPoint,
|
|
range: PassRange,
|
|
thresholdDeg: number,
|
|
stepMs: number
|
|
): PassWindow[] {
|
|
if (range.toMs <= range.fromMs) {
|
|
return [];
|
|
}
|
|
|
|
const windows: PassWindow[] = [];
|
|
let inside = false;
|
|
let startMs = range.fromMs;
|
|
let bestMargin = 0;
|
|
|
|
for (let timeMs = range.fromMs; timeMs <= range.toMs; timeMs += stepMs) {
|
|
const distance = greatCircleDistanceDeg(subPoint(orbit, timeMs), target);
|
|
const visible = distance <= thresholdDeg;
|
|
|
|
if (visible && !inside) {
|
|
inside = true;
|
|
startMs = timeMs;
|
|
bestMargin = thresholdDeg - distance;
|
|
} else if (visible) {
|
|
bestMargin = Math.max(bestMargin, thresholdDeg - distance);
|
|
} else if (inside) {
|
|
windows.push(toWindow(startMs, timeMs, bestMargin, thresholdDeg));
|
|
inside = false;
|
|
}
|
|
}
|
|
|
|
if (inside) {
|
|
windows.push(toWindow(startMs, range.toMs, bestMargin, thresholdDeg));
|
|
}
|
|
|
|
return windows.filter((window) => window.endMs - window.startMs >= MIN_WINDOW_MS);
|
|
}
|
|
|
|
function toWindow(startMs: number, endMs: number, bestMargin: number, thresholdDeg: number): PassWindow {
|
|
return {
|
|
startMs,
|
|
endMs,
|
|
quality: Math.max(0, Math.min(1, bestMargin / thresholdDeg))
|
|
};
|
|
}
|
|
|
|
function orbitParams(orbit: PassOrbit): {
|
|
altitudeKm: number;
|
|
periodSeconds: number;
|
|
inclinationRad: number;
|
|
nodeRad: number;
|
|
phaseRad: number;
|
|
} {
|
|
const altitudeKm = orbit.altitudeKm ?? DEFAULT_ALTITUDE_KM;
|
|
const semiMajorAxis = EARTH_RADIUS_KM + altitudeKm;
|
|
const periodSeconds = 2 * Math.PI * Math.sqrt((semiMajorAxis * semiMajorAxis * semiMajorAxis) / EARTH_MU_KM3_S2);
|
|
const key = orbit.spacecraftId ?? "default-spacecraft";
|
|
|
|
return {
|
|
altitudeKm,
|
|
periodSeconds,
|
|
inclinationRad: (97.4 + hash01(key, 7) * 1.6) * DEG,
|
|
nodeRad: hash01(key, 3) * 360 * DEG,
|
|
phaseRad: hash01(key, 11) * 2 * Math.PI
|
|
};
|
|
}
|
|
|
|
function hash01(value: string, salt: number): number {
|
|
let hash = 2166136261 ^ salt;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash ^= value.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return ((hash >>> 0) % 100000) / 100000;
|
|
}
|