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