From 77b1fac79b77d9cdecfefd830bcdb4bf70251f46 Mon Sep 17 00:00:00 2001 From: accius Date: Sat, 31 Jan 2026 16:22:15 -0500 Subject: [PATCH] visualize dx spots --- public/index.html | 141 +++++++++++++++++++++++++++++++++++++++++++++- server.js | 130 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 3 deletions(-) diff --git a/public/index.html b/public/index.html index d3532e3..47bc726 100644 --- a/public/index.html +++ b/public/index.html @@ -735,6 +735,42 @@ return { data, loading, activeSource }; }; + // ============================================ + // DX PATHS HOOK - DX spots with locations for map visualization + // ============================================ + const useDXPaths = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchPaths = async () => { + try { + const response = await fetch('/api/dxcluster/paths'); + + if (response.ok) { + const paths = await response.json(); + setData(paths); + console.log('[DX Paths] Loaded', paths.length, 'paths'); + } else { + setData([]); + } + } catch (err) { + console.error('DX Paths error:', err); + setData([]); + } finally { + setLoading(false); + } + }; + + fetchPaths(); + // Refresh every 30 seconds + const interval = setInterval(fetchPaths, 30000); + return () => clearInterval(interval); + }, []); + + return { data, loading }; + }; + // ============================================ // MY SPOTS HOOK - Spots involving the user's callsign // ============================================ @@ -1346,7 +1382,7 @@ // ============================================ // LEAFLET MAP COMPONENT // ============================================ - const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, satellites }) => { + const WorldMap = ({ deLocation, dxLocation, onDXChange, potaSpots, mySpots, dxPaths, satellites }) => { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const tileLayerRef = useRef(null); @@ -1358,10 +1394,13 @@ const potaMarkersRef = useRef([]); const mySpotsMarkersRef = useRef([]); const mySpotsLinesRef = useRef([]); + const dxPathsLinesRef = useRef([]); + const dxPathsMarkersRef = useRef([]); const satMarkersRef = useRef([]); const satTracksRef = useRef([]); const [mapStyle, setMapStyle] = useState('dark'); const [showSatellites, setShowSatellites] = useState(true); + const [showDXPaths, setShowDXPaths] = useState(true); // Initialize map useEffect(() => { @@ -1577,6 +1616,75 @@ } }, [mySpots, deLocation]); + // Update DX paths - lines showing who spotted whom + useEffect(() => { + if (!mapInstanceRef.current) return; + const map = mapInstanceRef.current; + + // Remove old DX paths + dxPathsLinesRef.current.forEach(l => map.removeLayer(l)); + dxPathsLinesRef.current = []; + dxPathsMarkersRef.current.forEach(m => map.removeLayer(m)); + dxPathsMarkersRef.current = []; + + // Add new DX paths if enabled + if (showDXPaths && dxPaths && dxPaths.length > 0) { + dxPaths.forEach((path, index) => { + // Draw great circle line from spotter to DX station + const pathPoints = getGreatCirclePoints( + path.spotterLat, path.spotterLon, + path.dxLat, path.dxLon + ); + + // Use different colors based on band (derived from frequency) + const freq = parseFloat(path.freq); + let color = '#4488ff'; // Default blue + if (freq >= 1.8 && freq < 2) color = '#ff6666'; // 160m - red + else if (freq >= 3.5 && freq < 4) color = '#ff9966'; // 80m - orange + else if (freq >= 7 && freq < 7.5) color = '#ffcc66'; // 40m - yellow + else if (freq >= 10 && freq < 10.5) color = '#99ff66'; // 30m - lime + else if (freq >= 14 && freq < 14.5) color = '#66ff99'; // 20m - green + else if (freq >= 18 && freq < 18.5) color = '#66ffcc'; // 17m - teal + else if (freq >= 21 && freq < 21.5) color = '#66ccff'; // 15m - cyan + else if (freq >= 24 && freq < 25) color = '#6699ff'; // 12m - blue + else if (freq >= 28 && freq < 30) color = '#9966ff'; // 10m - purple + else if (freq >= 50 && freq < 54) color = '#ff66ff'; // 6m - magenta + + // Handle antimeridian crossing + const segments = Array.isArray(pathPoints[0]) ? pathPoints : [pathPoints]; + segments.forEach(segment => { + const line = L.polyline(segment, { + color: color, + weight: 1.5, + opacity: 0.5 + }).addTo(map); + dxPathsLinesRef.current.push(line); + }); + + // Add small markers at DX station end only (to reduce clutter) + const dxIcon = L.divIcon({ + className: '', + html: `
`, + iconSize: [6, 6], + iconAnchor: [3, 3] + }); + + const marker = L.marker([path.dxLat, path.dxLon], { icon: dxIcon }) + .bindPopup(` +
+ ${path.dxCall}
+ spotted by ${path.spotter}
+ ${path.freq} MHz
+ ${path.comment || ''}
+ ${path.time} +
+ `) + .addTo(map); + dxPathsMarkersRef.current.push(marker); + }); + } + }, [dxPaths, showDXPaths]); + useEffect(() => { if (!mapInstanceRef.current) return; const map = mapInstanceRef.current; @@ -1728,6 +1836,30 @@ > 🛰 {showSatellites ? 'SATS ON' : 'SATS OFF'} + + {/* DX Paths toggle */} + ); }; @@ -2033,7 +2165,7 @@ const LegacyLayout = ({ config, currentTime, utcTime, utcDate, localTime, localDate, deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange, - spaceWeather, bandConditions, potaSpots, dxCluster, contests, propagation, mySpots, satellites, + spaceWeather, bandConditions, potaSpots, dxCluster, dxPaths, contests, propagation, mySpots, satellites, onSettingsClick }) => { const bearing = calculateBearing(config.location.lat, config.location.lon, dxLocation.lat, dxLocation.lon); @@ -2194,6 +2326,7 @@ onDXChange={onDXChange} potaSpots={potaSpots.data} mySpots={mySpots.data} + dxPaths={dxPaths.data} satellites={satellites.positions} /> @@ -2676,6 +2809,7 @@ const bandConditions = useBandConditions(spaceWeather.data); const potaSpots = usePOTASpots(); const dxCluster = useDXCluster(config.dxClusterSource || 'auto'); + const dxPaths = useDXPaths(); const contests = useContests(); const propagation = usePropagation(config.location, dxLocation); const mySpots = useMySpots(config.callsign); @@ -2728,6 +2862,7 @@ bandConditions={bandConditions} potaSpots={potaSpots} dxCluster={dxCluster} + dxPaths={dxPaths} contests={contests} propagation={propagation} mySpots={mySpots} @@ -2912,7 +3047,7 @@ {/* CENTER - MAP */}
- +
Click map to set DX • 73 de {config.callsign}
diff --git a/server.js b/server.js index fe82ced..1589e65 100644 --- a/server.js +++ b/server.js @@ -334,6 +334,136 @@ app.get('/api/dxcluster/sources', (req, res) => { ]); }); +// ============================================ +// DX SPOT PATHS API - Get spots with locations for map visualization +// Returns spots from the last 5 minutes with spotter and DX locations +// ============================================ + +// Cache for DX spot paths to avoid excessive lookups +let dxSpotPathsCache = { paths: [], timestamp: 0 }; +const DXPATHS_CACHE_TTL = 30000; // 30 seconds cache + +app.get('/api/dxcluster/paths', async (req, res) => { + // Check cache first + if (Date.now() - dxSpotPathsCache.timestamp < DXPATHS_CACHE_TTL && dxSpotPathsCache.paths.length > 0) { + console.log('[DX Paths] Returning', dxSpotPathsCache.paths.length, 'cached paths'); + return res.json(dxSpotPathsCache.paths); + } + + try { + // Get recent DX spots from HamQTH + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=50', { + headers: { 'User-Agent': 'OpenHamClock/3.5' }, + signal: controller.signal + }); + clearTimeout(timeout); + + if (!response.ok) { + return res.json([]); + } + + const text = await response.text(); + const lines = text.trim().split('\n').filter(line => line.includes('^')); + + // Parse spots and filter to last 5 minutes + const now = new Date(); + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000); + const spots = []; + + for (const line of lines) { + const parts = line.split('^'); + if (parts.length < 5) continue; + + const spotter = parts[0]?.trim().toUpperCase(); + const freqKhz = parseFloat(parts[1]) || 0; + const dxCall = parts[2]?.trim().toUpperCase(); + const comment = parts[3]?.trim() || ''; + const timeDate = parts[4]?.trim() || ''; + + if (!spotter || !dxCall || freqKhz <= 0) continue; + + // Parse time: "2149 2025-05-27" -> check if within last 5 minutes + // Note: HamQTH shows UTC time, format is "HHMM YYYY-MM-DD" + let spotTime = null; + if (timeDate.length >= 15) { + const timeStr = timeDate.substring(0, 4); // HHMM + const dateStr = timeDate.substring(5); // YYYY-MM-DD + const hours = parseInt(timeStr.substring(0, 2)); + const minutes = parseInt(timeStr.substring(2, 4)); + spotTime = new Date(`${dateStr}T${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:00Z`); + } + + // Include spot if we couldn't parse time or if it's within 5 minutes + if (!spotTime || spotTime >= fiveMinutesAgo) { + spots.push({ + spotter, + dxCall, + freq: (freqKhz / 1000).toFixed(3), + comment, + time: timeDate.length >= 4 ? timeDate.substring(0, 2) + ':' + timeDate.substring(2, 4) + 'z' : '' + }); + } + } + + // Get unique callsigns to look up + const allCalls = new Set(); + spots.forEach(s => { + allCalls.add(s.spotter); + allCalls.add(s.dxCall); + }); + + // Look up locations for all callsigns (limit to 40 to avoid timeouts) + const locations = {}; + const callsToLookup = [...allCalls].slice(0, 40); + + for (const call of callsToLookup) { + const loc = estimateLocationFromPrefix(call); + if (loc) { + locations[call] = { lat: loc.lat, lon: loc.lon, country: loc.country }; + } + } + + // Build paths with both locations + const paths = spots + .map(spot => { + const spotterLoc = locations[spot.spotter]; + const dxLoc = locations[spot.dxCall]; + + if (spotterLoc && dxLoc) { + return { + spotter: spot.spotter, + spotterLat: spotterLoc.lat, + spotterLon: spotterLoc.lon, + spotterCountry: spotterLoc.country, + dxCall: spot.dxCall, + dxLat: dxLoc.lat, + dxLon: dxLoc.lon, + dxCountry: dxLoc.country, + freq: spot.freq, + comment: spot.comment, + time: spot.time + }; + } + return null; + }) + .filter(p => p !== null) + .slice(0, 25); // Limit to 25 paths to avoid cluttering the map + + console.log('[DX Paths]', paths.length, 'paths with locations from', spots.length, 'spots'); + + // Update cache + dxSpotPathsCache = { paths, timestamp: Date.now() }; + + res.json(paths); + } catch (error) { + console.error('[DX Paths] Error:', error.message); + res.json([]); + } +}); + // ============================================ // CALLSIGN LOOKUP API (for getting location from callsign) // ============================================