import { useState, useEffect, useRef } from 'react'; /** * WSPR Propagation Heatmap Plugin v1.1.0 * * Visualizes global WSPR (Weak Signal Propagation Reporter) activity as: * - Great circle curved path lines between transmitters and receivers * - Color-coded by signal strength (SNR) * - Animated signal pulses along paths * - Statistics display (total stations, spots) * - Signal strength legend * - Optional band filtering * - Real-time propagation visualization * * Data source: PSK Reporter API (WSPR mode spots) * Update interval: 5 minutes */ export const metadata = { id: 'wspr', name: 'WSPR Propagation', description: 'Live WSPR spots showing global HF propagation paths with curved great circle routes', icon: '📡', category: 'propagation', defaultEnabled: false, defaultOpacity: 0.7, version: '1.1.0' }; // Convert grid square to lat/lon function gridToLatLon(grid) { if (!grid || grid.length < 4) return null; grid = grid.toUpperCase(); const lon = (grid.charCodeAt(0) - 65) * 20 - 180; const lat = (grid.charCodeAt(1) - 65) * 10 - 90; const lon2 = parseInt(grid[2]) * 2; const lat2 = parseInt(grid[3]); let longitude = lon + lon2 + 1; let latitude = lat + lat2 + 0.5; if (grid.length >= 6) { const lon3 = (grid.charCodeAt(4) - 65) * (2/24); const lat3 = (grid.charCodeAt(5) - 65) * (1/24); longitude = lon + lon2 + lon3 + (1/24); latitude = lat + lat2 + lat3 + (0.5/24); } return { lat: latitude, lon: longitude }; } // Get color based on SNR 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; } // Calculate great circle path between two points // Returns array of lat/lon points forming a smooth curve function getGreatCirclePath(lat1, lon1, lat2, lon2, numPoints = 30) { // Validate input coordinates if (!isFinite(lat1) || !isFinite(lon1) || !isFinite(lat2) || !isFinite(lon2)) { console.warn('Invalid coordinates for great circle:', { lat1, lon1, lat2, lon2 }); return [[lat1, lon1], [lat2, lon2]]; // Fallback to straight line } // Check if points are very close (less than 1 degree) const deltaLat = Math.abs(lat2 - lat1); const deltaLon = Math.abs(lon2 - lon1); if (deltaLat < 0.5 && deltaLon < 0.5) { // Points too close, use simple line return [[lat1, lon1], [lat2, lon2]]; } const path = []; // Convert to radians const toRad = (deg) => (deg * Math.PI) / 180; const toDeg = (rad) => (rad * 180) / Math.PI; const lat1Rad = toRad(lat1); const lon1Rad = toRad(lon1); const lat2Rad = toRad(lat2); const lon2Rad = toRad(lon2); // Calculate great circle distance const cosD = Math.sin(lat1Rad) * Math.sin(lat2Rad) + Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.cos(lon2Rad - lon1Rad); // Clamp to [-1, 1] to avoid NaN from Math.acos const d = Math.acos(Math.max(-1, Math.min(1, cosD))); // Check if distance is too small or points are antipodal if (d < 0.01 || Math.abs(d - Math.PI) < 0.01) { // Use simple line for very small or antipodal distances return [[lat1, lon1], [lat2, lon2]]; } const sinD = Math.sin(d); // Generate intermediate points along the great circle for (let i = 0; i <= numPoints; i++) { const f = i / numPoints; const A = Math.sin((1 - f) * d) / sinD; const B = Math.sin(f * d) / sinD; const x = A * Math.cos(lat1Rad) * Math.cos(lon1Rad) + B * Math.cos(lat2Rad) * Math.cos(lon2Rad); const y = A * Math.cos(lat1Rad) * Math.sin(lon1Rad) + B * Math.cos(lat2Rad) * Math.sin(lon2Rad); const z = A * Math.sin(lat1Rad) + B * Math.sin(lat2Rad); const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y))); const lon = toDeg(Math.atan2(y, x)); // Validate computed point if (isFinite(lat) && isFinite(lon)) { path.push([lat, lon]); } } // If path generation failed, fall back to straight line if (path.length < 2) { return [[lat1, lon1], [lat2, lon2]]; } return path; } export function useLayer({ enabled = false, opacity = 0.7, map = null }) { const [pathLayers, setPathLayers] = useState([]); const [markerLayers, setMarkerLayers] = useState([]); const [wsprData, setWsprData] = useState([]); const [bandFilter] = useState('all'); const [legendControl, setLegendControl] = useState(null); const [statsControl, setStatsControl] = useState(null); // Fetch WSPR data useEffect(() => { if (!enabled) return; const fetchWSPR = async () => { try { const response = await fetch(`/api/wspr/heatmap?minutes=30&band=${bandFilter}`); 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, bandFilter]); // Render WSPR paths on map useEffect(() => { if (!map || typeof L === 'undefined') return; 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(); const limitedData = wsprData.slice(0, 500); limitedData.forEach(spot => { // Validate spot coordinates if (!spot.senderLat || !spot.senderLon || !spot.receiverLat || !spot.receiverLon) { console.warn('[WSPR] Skipping spot with invalid coordinates:', spot); return; } // Ensure coordinates are valid numbers const sLat = parseFloat(spot.senderLat); const sLon = parseFloat(spot.senderLon); const rLat = parseFloat(spot.receiverLat); const rLon = parseFloat(spot.receiverLon); if (!isFinite(sLat) || !isFinite(sLon) || !isFinite(rLat) || !isFinite(rLon)) { console.warn('[WSPR] Skipping spot with non-finite coordinates:', { sLat, sLon, rLat, rLon }); return; } // Calculate great circle path for curved line const pathCoords = getGreatCirclePath(sLat, sLon, rLat, rLon, 30); // Skip if path is invalid if (!pathCoords || pathCoords.length < 2) { console.warn('[WSPR] Invalid path coordinates generated'); return; } const path = L.polyline(pathCoords, { color: getSNRColor(spot.snr), weight: getLineWeight(spot.snr), opacity: opacity * 0.6, smoothFactor: 1 }); 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(`
| TX: | ${spot.sender} (${spot.senderGrid}) |
| RX: | ${spot.receiver} (${spot.receiverGrid}) |
| Freq: | ${spot.freqMHz} MHz (${spot.band}) |
| SNR: | ${snrStr} |
| Time: | ${ageStr} |