From 72a6e588836c20056fad1ab672db6e89ecfb7c4a Mon Sep 17 00:00:00 2001 From: accius Date: Fri, 30 Jan 2026 15:17:20 -0500 Subject: [PATCH] Add live satellite tracking - ISS and ham radio sats with ground tracks --- public/index.html | 236 +++++++++++++++++++++++++++++++++++++++++++++- server.js | 116 +++++++++++++++++++++++ 2 files changed, 349 insertions(+), 3 deletions(-) diff --git a/public/index.html b/public/index.html index b0f59ab..1675b29 100644 --- a/public/index.html +++ b/public/index.html @@ -19,6 +19,9 @@ + + + @@ -937,6 +940,116 @@ return { data, loading }; }; + // ============================================ + // SATELLITE TRACKING HOOK + // ============================================ + const useSatellites = (deLocation) => { + const [tleData, setTleData] = useState({}); + const [positions, setPositions] = useState([]); + const [loading, setLoading] = useState(true); + + // Fetch TLE data on mount + useEffect(() => { + const fetchTLE = async () => { + try { + const response = await fetch('/api/satellites/tle'); + if (response.ok) { + const data = await response.json(); + setTleData(data); + console.log('[Satellites] Loaded TLE for', Object.keys(data).length, 'satellites'); + } + } catch (err) { + console.error('Satellite TLE fetch error:', err); + } finally { + setLoading(false); + } + }; + + fetchTLE(); + // Refresh TLE every 6 hours + const interval = setInterval(fetchTLE, 6 * 60 * 60 * 1000); + return () => clearInterval(interval); + }, []); + + // Calculate positions every second + useEffect(() => { + if (Object.keys(tleData).length === 0) return; + + const calculatePositions = () => { + const now = new Date(); + const newPositions = []; + + for (const [key, sat] of Object.entries(tleData)) { + if (!sat.tle1 || !sat.tle2) continue; + + try { + // Parse TLE and create satellite record + const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); + + // Get current position + const positionAndVelocity = satellite.propagate(satrec, now); + if (!positionAndVelocity.position) continue; + + // Convert to geodetic coordinates + const gmst = satellite.gstime(now); + const position = satellite.eciToGeodetic(positionAndVelocity.position, gmst); + + const lat = satellite.degreesLat(position.latitude); + const lon = satellite.degreesLong(position.longitude); + const alt = position.height; // km + + // Calculate if satellite is visible from DE location (above horizon) + const lookAngles = satellite.ecfToLookAngles( + { latitude: deLocation.lat * Math.PI / 180, longitude: deLocation.lon * Math.PI / 180, height: 0 }, + satellite.eciToEcf(positionAndVelocity.position, gmst) + ); + const elevation = lookAngles.elevation * 180 / Math.PI; + const azimuth = lookAngles.azimuth * 180 / Math.PI; + const isVisible = elevation > 0; + + // Calculate orbit track (next 90 minutes, point every minute) + const track = []; + for (let m = -30; m <= 60; m += 2) { + const futureTime = new Date(now.getTime() + m * 60000); + const futurePos = satellite.propagate(satrec, futureTime); + if (futurePos.position) { + const futureGmst = satellite.gstime(futureTime); + const futureGeo = satellite.eciToGeodetic(futurePos.position, futureGmst); + track.push([ + satellite.degreesLat(futureGeo.latitude), + satellite.degreesLong(futureGeo.longitude) + ]); + } + } + + newPositions.push({ + key, + name: sat.name, + color: sat.color, + lat, + lon, + alt: Math.round(alt), + elevation: elevation.toFixed(1), + azimuth: azimuth.toFixed(1), + isVisible, + track + }); + } catch (e) { + // Skip satellites with calculation errors + } + } + + setPositions(newPositions); + }; + + calculatePositions(); + const interval = setInterval(calculatePositions, 2000); // Update every 2 seconds + return () => clearInterval(interval); + }, [tleData, deLocation.lat, deLocation.lon]); + + return { positions, loading }; + }; + // ============================================ // PROPAGATION PANEL COMPONENT (Toggleable views) // ============================================ @@ -1225,7 +1338,7 @@ // ============================================ // LEAFLET MAP COMPONENT // ============================================ - const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots }) => { + const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, satellites }) => { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const tileLayerRef = useRef(null); @@ -1237,7 +1350,10 @@ const potaMarkersRef = useRef([]); const mySpotsMarkersRef = useRef([]); const mySpotsLinesRef = useRef([]); + const satMarkersRef = useRef([]); + const satTracksRef = useRef([]); const [mapStyle, setMapStyle] = useState('dark'); + const [showSatellites, setShowSatellites] = useState(true); // Initialize map useEffect(() => { @@ -1477,6 +1593,93 @@ }); }, [potaSpots]); + // Update satellite markers and tracks + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + // Remove old satellite markers and tracks + satMarkersRef.current.forEach(m => map.removeLayer(m)); + satMarkersRef.current = []; + satTracksRef.current.forEach(t => map.removeLayer(t)); + satTracksRef.current = []; + + // Add satellite markers and tracks if enabled + if (showSatellites && satellites && satellites.length > 0) { + satellites.forEach(sat => { + // Draw orbit track + if (sat.track && sat.track.length > 1) { + // Split track at antimeridian crossings + let segments = []; + let currentSegment = [sat.track[0]]; + + for (let i = 1; i < sat.track.length; i++) { + const prevLon = sat.track[i-1][1]; + const currLon = sat.track[i][1]; + + if (Math.abs(currLon - prevLon) > 180) { + segments.push(currentSegment); + currentSegment = []; + } + currentSegment.push(sat.track[i]); + } + segments.push(currentSegment); + + segments.forEach(segment => { + if (segment.length > 1) { + const track = L.polyline(segment, { + color: sat.color, + weight: 1.5, + opacity: 0.5, + dashArray: '4, 8' + }).addTo(map); + satTracksRef.current.push(track); + } + }); + } + + // Create satellite marker + const isISS = sat.key === 'ISS'; + const icon = L.divIcon({ + className: '', + html: `
🛰 ${sat.key}
`, + iconAnchor: [isISS ? 35 : 25, 12] + }); + + const marker = L.marker([sat.lat, sat.lon], { icon, zIndexOffset: isISS ? 1000 : 500 }) + .bindPopup(` +
+ 🛰 ${sat.name}
+
+ Altitude: ${sat.alt} km
+ Position: ${sat.lat.toFixed(2)}°, ${sat.lon.toFixed(2)}°
+ ${sat.isVisible ? + `✓ VISIBLE
+ Azimuth: ${sat.azimuth}°
+ Elevation: ${sat.elevation}°` : + `Below horizon` + } +
+
+ `) + .addTo(map); + satMarkersRef.current.push(marker); + }); + } + }, [satellites, showSatellites]); + return (
@@ -1493,6 +1696,30 @@ ))}
+ + {/* Satellite toggle */} +
); }; @@ -1792,7 +2019,7 @@ const LegacyLayout = ({ config, currentTime, utcTime, utcDate, localTime, localDate, deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange, - spaceWeather, bandConditions, potaSpots, dxCluster, contests, propagation, mySpots, + spaceWeather, bandConditions, potaSpots, dxCluster, contests, propagation, mySpots, satellites, onSettingsClick }) => { const bearing = calculateBearing(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon); @@ -1953,6 +2180,7 @@ onDXChange={onDXChange} potaSpots={potaSpots.data} mySpots={mySpots.data} + satellites={satellites.positions} /> @@ -2403,6 +2631,7 @@ const contests = useContests(); const propagation = usePropagation(config.location, dxLocation); const mySpots = useMySpots(config.callsign); + const satellites = useSatellites(config.location); const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]); @@ -2454,6 +2683,7 @@ contests={contests} propagation={propagation} mySpots={mySpots} + satellites={satellites} onSettingsClick={() => setShowSettings(true)} /> - +
Click map to set DX • 73 de {config.callsign}
diff --git a/server.js b/server.js index bd19e28..e2847ea 100644 --- a/server.js +++ b/server.js @@ -485,6 +485,122 @@ app.get('/api/myspots/:callsign', async (req, res) => { } }); +// ============================================ +// SATELLITE TRACKING API +// ============================================ + +// Ham radio satellites - NORAD IDs +const HAM_SATELLITES = { + 'ISS': { norad: 25544, name: 'ISS (ZARYA)', color: '#00ffff', priority: 1 }, + 'AO-91': { norad: 43017, name: 'AO-91 (Fox-1B)', color: '#ff6600', priority: 2 }, + 'AO-92': { norad: 43137, name: 'AO-92 (Fox-1D)', color: '#ff9900', priority: 2 }, + 'SO-50': { norad: 27607, name: 'SO-50 (SaudiSat)', color: '#00ff00', priority: 2 }, + 'RS-44': { norad: 44909, name: 'RS-44 (DOSAAF)', color: '#ff0066', priority: 2 }, + 'IO-117': { norad: 53106, name: 'IO-117 (GreenCube)', color: '#00ff99', priority: 3 }, + 'CAS-4A': { norad: 42761, name: 'CAS-4A (ZHUHAI-1 01)', color: '#9966ff', priority: 3 }, + 'CAS-4B': { norad: 42759, name: 'CAS-4B (ZHUHAI-1 02)', color: '#9933ff', priority: 3 }, + 'PO-101': { norad: 43678, name: 'PO-101 (Diwata-2)', color: '#ff3399', priority: 3 }, + 'TEVEL': { norad: 50988, name: 'TEVEL-1', color: '#66ccff', priority: 4 } +}; + +// Cache for TLE data (refresh every 6 hours) +let tleCache = { data: null, timestamp: 0 }; +const TLE_CACHE_DURATION = 6 * 60 * 60 * 1000; // 6 hours + +app.get('/api/satellites/tle', async (req, res) => { + console.log('[Satellites] Fetching TLE data...'); + + try { + const now = Date.now(); + + // Return cached data if fresh + if (tleCache.data && (now - tleCache.timestamp) < TLE_CACHE_DURATION) { + console.log('[Satellites] Returning cached TLE data'); + return res.json(tleCache.data); + } + + // Fetch fresh TLE data from CelesTrak + const tleData = {}; + + // Fetch amateur radio satellites TLE + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + + const response = await fetch( + 'https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle', + { + headers: { 'User-Agent': 'OpenHamClock/3.3' }, + signal: controller.signal + } + ); + clearTimeout(timeout); + + if (response.ok) { + const text = await response.text(); + const lines = text.trim().split('\n'); + + // Parse TLE data (3 lines per satellite: name, line1, line2) + for (let i = 0; i < lines.length - 2; i += 3) { + const name = lines[i].trim(); + const line1 = lines[i + 1]?.trim(); + const line2 = lines[i + 2]?.trim(); + + if (line1 && line2 && line1.startsWith('1 ') && line2.startsWith('2 ')) { + // Extract NORAD ID from line 1 + const noradId = parseInt(line1.substring(2, 7)); + + // Check if this is a satellite we care about + for (const [key, sat] of Object.entries(HAM_SATELLITES)) { + if (sat.norad === noradId) { + tleData[key] = { + ...sat, + tle1: line1, + tle2: line2 + }; + console.log('[Satellites] Found TLE for:', key, noradId); + } + } + } + } + } + + // Also try to get ISS specifically (it's in the stations group) + if (!tleData['ISS']) { + try { + const issResponse = await fetch( + 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=tle', + { headers: { 'User-Agent': 'OpenHamClock/3.3' } } + ); + if (issResponse.ok) { + const issText = await issResponse.text(); + const issLines = issText.trim().split('\n'); + if (issLines.length >= 3) { + tleData['ISS'] = { + ...HAM_SATELLITES['ISS'], + tle1: issLines[1].trim(), + tle2: issLines[2].trim() + }; + console.log('[Satellites] Found ISS TLE'); + } + } + } catch (e) { + console.log('[Satellites] Could not fetch ISS TLE:', e.message); + } + } + + // Cache the result + tleCache = { data: tleData, timestamp: now }; + + console.log('[Satellites] Loaded TLE for', Object.keys(tleData).length, 'satellites'); + res.json(tleData); + + } catch (error) { + console.error('[Satellites] TLE fetch error:', error.message); + // Return cached data even if stale, or empty object + res.json(tleCache.data || {}); + } +}); + // ============================================ // VOACAP / HF PROPAGATION PREDICTION API // ============================================