Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/passes.ts
T
Дмитрий Соловьев 4ab765b0ee feat(tgu-ops-ui): display real ground track and swath from ballistics service
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>
2026-05-31 16:05:41 +03:00

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