diff --git a/server.js b/server.js index abd7562..62a7cb1 100644 --- a/server.js +++ b/server.js @@ -2247,6 +2247,144 @@ app.get('/api/pskreporter/:callsign', async (req, res) => { }); } }); + +// ============================================ +// WSPR PROPAGATION HEATMAP API +// ============================================ + +// WSPR heatmap endpoint - gets global propagation data +// Uses PSK Reporter to fetch WSPR mode spots from the last N minutes +let wsprCache = { data: null, timestamp: 0 }; +const WSPR_CACHE_TTL = 5 * 60 * 1000; // 5 minutes cache + +app.get('/api/wspr/heatmap', async (req, res) => { + const minutes = parseInt(req.query.minutes) || 30; // Default 30 minutes + const band = req.query.band || 'all'; // all, 20m, 40m, etc. + const now = Date.now(); + + // Return cached data if fresh + const cacheKey = `${minutes}:${band}`; + if (wsprCache.data && + wsprCache.data.cacheKey === cacheKey && + (now - wsprCache.timestamp) < WSPR_CACHE_TTL) { + return res.json({ ...wsprCache.data.result, cached: true }); + } + + try { + const flowStartSeconds = -Math.abs(minutes * 60); + // Query PSK Reporter for WSPR mode spots (no specific callsign filter) + // Get data from multiple popular WSPR frequencies to build heatmap + const url = `https://retrieve.pskreporter.info/query?mode=WSPR&flowStartSeconds=${flowStartSeconds}&rronly=1&nolocator=0&appcontact=openhamclock&rptlimit=2000`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 20000); + + const response = await fetch(url, { + headers: { + 'User-Agent': 'OpenHamClock/3.12 (Amateur Radio Dashboard)', + 'Accept': '*/*' + }, + signal: controller.signal + }); + clearTimeout(timeout); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const xml = await response.text(); + const spots = []; + + // Parse XML response + const reportRegex = /]*>/g; + let match; + while ((match = reportRegex.exec(xml)) !== null) { + const report = match[0]; + const getAttr = (name) => { + const m = report.match(new RegExp(`${name}="([^"]*)"`)); + return m ? m[1] : null; + }; + + const receiverCallsign = getAttr('receiverCallsign'); + const receiverLocator = getAttr('receiverLocator'); + const senderCallsign = getAttr('senderCallsign'); + const senderLocator = getAttr('senderLocator'); + const frequency = getAttr('frequency'); + const mode = getAttr('mode'); + const flowStartSecs = getAttr('flowStartSeconds'); + const sNR = getAttr('sNR'); + + if (receiverCallsign && senderCallsign && senderLocator && receiverLocator) { + const freq = frequency ? parseInt(frequency) : null; + const spotBand = freq ? getBandFromHz(freq) : 'Unknown'; + + // Filter by band if specified + if (band !== 'all' && spotBand !== band) continue; + + const senderLoc = gridToLatLonSimple(senderLocator); + const receiverLoc = gridToLatLonSimple(receiverLocator); + + if (senderLoc && receiverLoc) { + spots.push({ + sender: senderCallsign, + senderGrid: senderLocator, + senderLat: senderLoc.lat, + senderLon: senderLoc.lon, + receiver: receiverCallsign, + receiverGrid: receiverLocator, + receiverLat: receiverLoc.lat, + receiverLon: receiverLoc.lon, + freq: freq, + freqMHz: freq ? (freq / 1000000).toFixed(3) : null, + band: spotBand, + snr: sNR ? parseInt(sNR) : null, + timestamp: flowStartSecs ? parseInt(flowStartSecs) * 1000 : Date.now(), + age: flowStartSecs ? Math.floor((Date.now() / 1000 - parseInt(flowStartSecs)) / 60) : 0 + }); + } + } + } + + // Sort by timestamp (newest first) + spots.sort((a, b) => b.timestamp - a.timestamp); + + const result = { + count: spots.length, + spots: spots, + minutes: minutes, + band: band, + timestamp: new Date().toISOString(), + source: 'pskreporter' + }; + + // Cache it + wsprCache = { + data: { result, cacheKey }, + timestamp: now + }; + + console.log(`[WSPR Heatmap] Found ${spots.length} WSPR spots (${minutes}min, band: ${band})`); + res.json(result); + + } catch (error) { + logErrorOnce('WSPR Heatmap', error.message); + + // Return cached data if available + if (wsprCache.data && wsprCache.data.cacheKey === cacheKey) { + return res.json({ ...wsprCache.data.result, cached: true, stale: true }); + } + + // Return empty result + res.json({ + count: 0, + spots: [], + minutes, + band, + error: error.message + }); + } +}); + // ============================================ // SATELLITE TRACKING API // ============================================ diff --git a/src/plugins/layerRegistry.js b/src/plugins/layerRegistry.js index 013e816..d39ffdb 100644 --- a/src/plugins/layerRegistry.js +++ b/src/plugins/layerRegistry.js @@ -5,11 +5,13 @@ import * as WXRadarPlugin from './layers/useWXRadar.js'; import * as EarthquakesPlugin from './layers/useEarthquakes.js'; import * as AuroraPlugin from './layers/useAurora.js'; +import * as WSPRPlugin from './layers/useWSPR.js'; const layerPlugins = [ WXRadarPlugin, EarthquakesPlugin, AuroraPlugin, + WSPRPlugin, ]; export function getAllLayers() { diff --git a/src/plugins/layers/useWSPR.js b/src/plugins/layers/useWSPR.js new file mode 100644 index 0000000..4a7b5fb --- /dev/null +++ b/src/plugins/layers/useWSPR.js @@ -0,0 +1,202 @@ +import { useState, useEffect } from 'react'; + +/** + * WSPR Propagation Heatmap Plugin + * + * Visualizes global WSPR (Weak Signal Propagation Reporter) activity as: + * - Path lines between transmitters and receivers + * - Color-coded by signal strength (SNR) + * - Real-time propagation visualization + */ + +export const metadata = { + id: 'wspr', + name: 'WSPR Propagation', + description: 'Live WSPR spots showing global HF propagation paths (last 30 min)', + icon: '📡', + category: 'propagation', + defaultEnabled: false, + defaultOpacity: 0.7, + version: '1.0.0' +}; + +// Get color based on SNR (Signal-to-Noise Ratio) +function getSNRColor(snr) { + if (snr === null || snr === undefined) return '#888888'; + if (snr < -20) return '#ff0000'; + if (snr < -10) return '#ff6600'; + if (snr < 0) return '#ffaa00'; + if (snr < 5) return '#ffff00'; + return '#00ff00'; +} + +// Get line weight based on SNR +function getLineWeight(snr) { + if (snr === null || snr === undefined) return 1; + if (snr < -20) return 1; + if (snr < -10) return 1.5; + if (snr < 0) return 2; + if (snr < 5) return 2.5; + return 3; +} + +export function useLayer({ enabled = false, opacity = 0.7, map = null }) { + const [pathLayers, setPathLayers] = useState([]); + const [markerLayers, setMarkerLayers] = useState([]); + const [wsprData, setWsprData] = useState([]); + + // Fetch WSPR data + useEffect(() => { + if (!enabled) return; + + const fetchWSPR = async () => { + try { + const response = await fetch('/api/wspr/heatmap?minutes=30&band=all'); + if (response.ok) { + const data = await response.json(); + setWsprData(data.spots || []); + console.log(`[WSPR Plugin] Loaded ${data.spots?.length || 0} spots`); + } + } catch (err) { + console.error('WSPR data fetch error:', err); + } + }; + + fetchWSPR(); + const interval = setInterval(fetchWSPR, 300000); + return () => clearInterval(interval); + }, [enabled]); + + // Render WSPR paths on map + useEffect(() => { + if (!map || typeof L === 'undefined') return; + + // Clear old layers + pathLayers.forEach(layer => { + try { map.removeLayer(layer); } catch (e) {} + }); + markerLayers.forEach(layer => { + try { map.removeLayer(layer); } catch (e) {} + }); + setPathLayers([]); + setMarkerLayers([]); + + if (!enabled || wsprData.length === 0) return; + + const newPaths = []; + const newMarkers = []; + const txStations = new Set(); + const rxStations = new Set(); + + // Limit to most recent 500 spots for performance + const limitedData = wsprData.slice(0, 500); + + limitedData.forEach(spot => { + // Draw path line from sender to receiver + const path = L.polyline( + [ + [spot.senderLat, spot.senderLon], + [spot.receiverLat, spot.receiverLon] + ], + { + color: getSNRColor(spot.snr), + weight: getLineWeight(spot.snr), + opacity: opacity * 0.6, + dashArray: '5, 5' + } + ); + + // Add popup to path + const snrStr = spot.snr !== null ? `${spot.snr} dB` : 'N/A'; + const ageStr = spot.age < 60 ? `${spot.age} min ago` : `${Math.floor(spot.age / 60)}h ago`; + + path.bindPopup(` +
+
+ 📡 WSPR Spot +
+ + + + + + +
TX:${spot.sender} (${spot.senderGrid})
RX:${spot.receiver} (${spot.receiverGrid})
Freq:${spot.freqMHz} MHz (${spot.band})
SNR:${snrStr}
Time:${ageStr}
+
+ `); + + path.addTo(map); + newPaths.push(path); + + // Add transmitter marker + const txKey = `${spot.sender}-${spot.senderGrid}`; + if (!txStations.has(txKey)) { + txStations.add(txKey); + const txMarker = L.circleMarker([spot.senderLat, spot.senderLon], { + radius: 4, + fillColor: '#ff6600', + color: '#ffffff', + weight: 1, + fillOpacity: opacity * 0.8, + opacity: opacity + }); + txMarker.bindTooltip(`TX: ${spot.sender}`, { permanent: false, direction: 'top' }); + txMarker.addTo(map); + newMarkers.push(txMarker); + } + + // Add receiver marker + const rxKey = `${spot.receiver}-${spot.receiverGrid}`; + if (!rxStations.has(rxKey)) { + rxStations.add(rxKey); + const rxMarker = L.circleMarker([spot.receiverLat, spot.receiverLon], { + radius: 4, + fillColor: '#0088ff', + color: '#ffffff', + weight: 1, + fillOpacity: opacity * 0.8, + opacity: opacity + }); + rxMarker.bindTooltip(`RX: ${spot.receiver}`, { permanent: false, direction: 'top' }); + rxMarker.addTo(map); + newMarkers.push(rxMarker); + } + }); + + setPathLayers(newPaths); + setMarkerLayers(newMarkers); + console.log(`[WSPR Plugin] Rendered ${newPaths.length} paths, ${newMarkers.length} markers`); + + return () => { + newPaths.forEach(layer => { + try { map.removeLayer(layer); } catch (e) {} + }); + newMarkers.forEach(layer => { + try { map.removeLayer(layer); } catch (e) {} + }); + }; + }, [enabled, wsprData, map, opacity]); + + // Update opacity when it changes + useEffect(() => { + pathLayers.forEach(layer => { + if (layer.setStyle) { + layer.setStyle({ opacity: opacity * 0.6 }); + } + }); + markerLayers.forEach(layer => { + if (layer.setStyle) { + layer.setStyle({ + fillOpacity: opacity * 0.8, + opacity: opacity + }); + } + }); + }, [opacity, pathLayers, markerLayers]); + + return { + paths: pathLayers, + markers: markerLayers, + spotCount: wsprData.length + }; +}