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
// ============================================