import * as satellite from 'satellite.js'; import type { Satellite, SatellitePosition } from '../types'; // CelesTrak TLE groups to fetch — relevant to Middle East theater const CELESTRAK_GROUPS: { group: string; category: Satellite['category'] }[] = [ { group: 'military', category: 'reconnaissance' }, { group: 'gps-ops', category: 'navigation' }, { group: 'geo', category: 'communications' }, { group: 'weather', category: 'weather' }, { group: 'stations', category: 'other' }, ]; // Category override by satellite name keywords function refineSatCategory(name: string, defaultCat: Satellite['category']): Satellite['category'] { const n = name.toUpperCase(); if (n.includes('SBIRS') || n.includes('NROL') || n.includes('USA') || n.includes('KEYHOLE') || n.includes('LACROSSE')) return 'reconnaissance'; if (n.includes('WGS') || n.includes('AEHF') || n.includes('MUOS') || n.includes('STARLINK') || n.includes('MILSTAR')) return 'communications'; if (n.includes('GPS') || n.includes('NAVSTAR') || n.includes('GALILEO') || n.includes('BEIDOU') || n.includes('GLONASS')) return 'navigation'; if (n.includes('GOES') || n.includes('METOP') || n.includes('NOAA') || n.includes('METEOR') || n.includes('DMSP')) return 'weather'; if (n.includes('ISS')) return 'other'; return defaultCat; } // Parse 3-line TLE format (name + line1 + line2) function parseTLE(text: string, defaultCategory: Satellite['category']): Satellite[] { const lines = text.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0); const sats: Satellite[] = []; for (let i = 0; i < lines.length - 2; i++) { // TLE line 1 starts with "1 ", line 2 starts with "2 " if (lines[i + 1].startsWith('1 ') && lines[i + 2].startsWith('2 ')) { const name = lines[i]; const tle1 = lines[i + 1]; const tle2 = lines[i + 2]; // Extract NORAD catalog number from line 1 (columns 3-7) const noradId = parseInt(tle1.substring(2, 7).trim(), 10); if (isNaN(noradId)) continue; sats.push({ noradId, name, tle1, tle2, category: refineSatCategory(name, defaultCategory), }); i += 2; // skip the 2 TLE lines } } return sats; } // Middle East bounding box for filtering LEO satellites // Only keep satellites whose ground track passes near the region (lat 15-45, lon 25-65) function isNearMiddleEast(sat: Satellite): boolean { try { const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); const now = new Date(); // Check current position and ±45min positions for (const offsetMin of [0, -45, 45, -90, 90]) { const t = new Date(now.getTime() + offsetMin * 60_000); const pv = satellite.propagate(satrec, t); if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; const gmst = satellite.gstime(t); const geo = satellite.eciToGeodetic(pv.position, gmst); const lat = satellite.degreesLat(geo.latitude); const lng = satellite.degreesLong(geo.longitude); // Generous bounding: lat -5 to 55, lon 15 to 75 if (lat >= -5 && lat <= 55 && lng >= 15 && lng <= 75) return true; } return false; } catch { return false; } } // Satellite cache — avoid re-fetching within 10 minutes let satCache: { sats: Satellite[]; ts: number } | null = null; const SAT_CACHE_TTL = 10 * 60_000; async function fetchSatellitesFromBackend(region: 'iran' | 'korea' = 'iran'): Promise { try { const res = await fetch(`/api/kcg/satellites?region=${region}`, { credentials: 'include' }); if (!res.ok) return []; const data = await res.json(); return (data.satellites ?? []) as Satellite[]; } catch { return []; } } async function fetchSatelliteTLEFromCelesTrak(): Promise { const allSats: Satellite[] = []; const seenIds = new Set(); for (const { group, category } of CELESTRAK_GROUPS) { try { const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`; const res = await fetch(url); if (!res.ok) { console.warn(`CelesTrak ${group}: ${res.status}`); continue; } const text = await res.text(); const parsed = parseTLE(text, category); for (const sat of parsed) { if (!seenIds.has(sat.noradId)) { seenIds.add(sat.noradId); allSats.push(sat); } } } catch (err) { console.warn(`CelesTrak ${group} fetch failed:`, err); } } return allSats; } function filterSatellitesByRegion(allSats: Satellite[], isNearFn: (sat: Satellite) => boolean): Satellite[] { const filtered: Satellite[] = []; for (const sat of allSats) { try { const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); const pv = satellite.propagate(satrec, new Date()); if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date())); const altKm = geo.height; if (altKm > 5000) { filtered.push(sat); } else { if (isNearFn(sat)) { filtered.push(sat); } } } catch { // skip bad TLE } } return filtered.slice(0, 100); } export async function fetchSatelliteTLE(): Promise { if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) { return satCache.sats; } // 백엔드 API 우선 let allSats = await fetchSatellitesFromBackend('iran'); // 백엔드 실패 시 CelesTrak 직접 호출 fallback if (allSats.length === 0) { allSats = await fetchSatelliteTLEFromCelesTrak(); } if (allSats.length === 0) { console.warn('CelesTrak: no data fetched, using fallback'); return FALLBACK_SATELLITES; } const capped = filterSatellitesByRegion(allSats, isNearMiddleEast); satCache = { sats: capped, ts: Date.now() }; console.log(`Satellites: loaded ${capped.length} (from ${allSats.length} total)`); return capped; } // ═══ Korea region satellite fetch ═══ function isNearKorea(sat: Satellite): boolean { try { const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); const now = new Date(); for (const offsetMin of [0, -45, 45, -90, 90]) { const t = new Date(now.getTime() + offsetMin * 60_000); const pv = satellite.propagate(satrec, t); if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; const gmst = satellite.gstime(t); const geo = satellite.eciToGeodetic(pv.position, gmst); const lat = satellite.degreesLat(geo.latitude); const lng = satellite.degreesLong(geo.longitude); if (lat >= 10 && lat <= 50 && lng >= 110 && lng <= 150) return true; } return false; } catch { return false; } } let satCacheKorea: { sats: Satellite[]; ts: number } | null = null; export async function fetchSatelliteTLEKorea(): Promise { if (satCacheKorea && Date.now() - satCacheKorea.ts < SAT_CACHE_TTL) { return satCacheKorea.sats; } // 백엔드 API 우선 let allSats = await fetchSatellitesFromBackend('korea'); // 백엔드 실패 시 CelesTrak 직접 호출 fallback if (allSats.length === 0) { allSats = await fetchSatelliteTLEFromCelesTrak(); } if (allSats.length === 0) return FALLBACK_SATELLITES; const capped = filterSatellitesByRegion(allSats, isNearKorea); satCacheKorea = { sats: capped, ts: Date.now() }; console.log(`Satellites Korea: loaded ${capped.length} (from ${allSats.length} total)`); return capped; } // Fallback satellites if CelesTrak is unreachable const FALLBACK_SATELLITES: Satellite[] = [ { noradId: 25544, name: 'ISS (ZARYA)', category: 'other', tle1: '1 25544U 98067A 26060.50000000 .00016717 00000-0 10270-3 0 9999', tle2: '2 25544 51.6400 210.0000 0005000 350.0000 10.0000 15.49000000 10000' }, { noradId: 37481, name: 'SBIRS GEO-1', category: 'reconnaissance', tle1: '1 37481U 11019A 26060.50000000 .00000010 00000-0 00000-0 0 9999', tle2: '2 37481 3.5000 60.0000 0003000 90.0000 270.0000 1.00270000 10000' }, { noradId: 44478, name: 'WGS-10', category: 'communications', tle1: '1 44478U 19060A 26060.50000000 .00000010 00000-0 00000-0 0 9999', tle2: '2 44478 0.1000 60.0000 0002000 270.0000 90.0000 1.00270000 10000' }, { noradId: 55268, name: 'GPS III-06', category: 'navigation', tle1: '1 55268U 23008A 26060.50000000 .00000010 00000-0 00000-0 0 9999', tle2: '2 55268 55.0000 60.0000 0050000 100.0000 260.0000 2.00600000 10000' }, { noradId: 43689, name: 'MetOp-C', category: 'weather', tle1: '1 43689U 18096A 26060.50000000 .00000400 00000-0 20000-3 0 9999', tle2: '2 43689 98.7000 110.0000 0002000 90.0000 270.0000 14.21000000 10000' }, ]; // Cache satrec objects (expensive to create) const satrecCache = new Map>(); function getSatrec(sat: Satellite) { let rec = satrecCache.get(sat.noradId); if (!rec) { rec = satellite.twoline2satrec(sat.tle1, sat.tle2); satrecCache.set(sat.noradId, rec); } return rec; } // Cache ground tracks — only recompute every 60s const trackCache = new Map(); const TRACK_CACHE_MS = 60_000; export function propagateSatellite( sat: Satellite, time: Date, trackMinutes: number = 90, ): SatellitePosition | null { try { const satrec = getSatrec(sat); const posVel = satellite.propagate(satrec, time); if (!posVel || typeof posVel.position === 'boolean' || !posVel.position) return null; const pos = posVel.position; const gmst = satellite.gstime(time); const geo = satellite.eciToGeodetic(pos, gmst); const lat = satellite.degreesLat(geo.latitude); const lng = satellite.degreesLong(geo.longitude); const altitude = geo.height; // Ground track — use cache if fresh enough const cached = trackCache.get(sat.noradId); let groundTrack: [number, number][]; if (cached && Math.abs(cached.time - time.getTime()) < TRACK_CACHE_MS) { groundTrack = cached.track; } else { groundTrack = []; const steps = 20; // reduced from 60 const stepMs = (trackMinutes * 60 * 1000) / steps; for (let i = -steps / 2; i <= steps / 2; i++) { const t = new Date(time.getTime() + i * stepMs); const pv = satellite.propagate(satrec, t); if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; const g = satellite.gstime(t); const gd = satellite.eciToGeodetic(pv.position, g); groundTrack.push([ satellite.degreesLat(gd.latitude), satellite.degreesLong(gd.longitude), ]); } trackCache.set(sat.noradId, { time: time.getTime(), track: groundTrack }); } return { noradId: sat.noradId, name: sat.name, lat, lng, altitude, category: sat.category, groundTrack, }; } catch { return null; } } export function propagateAll( satellites: Satellite[], time: Date, ): SatellitePosition[] { return satellites .map(s => propagateSatellite(s, time)) .filter((p): p is SatellitePosition => p !== null); }