feat: Add WSPR Propagation Heatmap plugin

- Added /api/wspr/heatmap endpoint to server.js for fetching WSPR spots from PSK Reporter
- Created useWSPR.js plugin with real-time propagation path visualization
- Features:
  - Path lines between TX and RX stations color-coded by SNR
  - Signal strength visualization (red=weak, green=strong)
  - Automatic refresh every 5 minutes
  - Displays up to 500 most recent spots (last 30 minutes)
  - Band filtering support (20m, 40m, etc.)
  - Detailed popups showing TX/RX info, frequency, SNR, age
- Plugin fully self-contained in layers/ directory per project architecture
- Registered in layerRegistry.js for Settings panel integration
- Zero changes required to core WorldMap component

Data source: PSK Reporter API (WSPR mode)
Category: Propagation
Default: Disabled (user can enable in Settings → Map Layers)
pull/82/head
trancen 2 days ago
parent 8eb0a5b881
commit 026ba6c659

@ -6,7 +6,11 @@ import { useState, useEffect } from 'react';
* Visualizes global WSPR (Weak Signal Propagation Reporter) activity as: * Visualizes global WSPR (Weak Signal Propagation Reporter) activity as:
* - Path lines between transmitters and receivers * - Path lines between transmitters and receivers
* - Color-coded by signal strength (SNR) * - Color-coded by signal strength (SNR)
* - Optional band filtering
* - Real-time propagation visualization * - Real-time propagation visualization
*
* Data source: PSK Reporter API (WSPR mode spots)
* Update interval: 5 minutes
*/ */
export const metadata = { export const metadata = {
@ -20,7 +24,30 @@ export const metadata = {
version: '1.0.0' version: '1.0.0'
}; };
// Get color based on SNR (Signal-to-Noise Ratio) // 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) { function getSNRColor(snr) {
if (snr === null || snr === undefined) return '#888888'; if (snr === null || snr === undefined) return '#888888';
if (snr < -20) return '#ff0000'; if (snr < -20) return '#ff0000';
@ -44,6 +71,7 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
const [pathLayers, setPathLayers] = useState([]); const [pathLayers, setPathLayers] = useState([]);
const [markerLayers, setMarkerLayers] = useState([]); const [markerLayers, setMarkerLayers] = useState([]);
const [wsprData, setWsprData] = useState([]); const [wsprData, setWsprData] = useState([]);
const [bandFilter] = useState('all');
// Fetch WSPR data // Fetch WSPR data
useEffect(() => { useEffect(() => {
@ -51,7 +79,7 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
const fetchWSPR = async () => { const fetchWSPR = async () => {
try { try {
const response = await fetch('/api/wspr/heatmap?minutes=30&band=all'); const response = await fetch(`/api/wspr/heatmap?minutes=30&band=${bandFilter}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setWsprData(data.spots || []); setWsprData(data.spots || []);
@ -64,19 +92,23 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
fetchWSPR(); fetchWSPR();
const interval = setInterval(fetchWSPR, 300000); const interval = setInterval(fetchWSPR, 300000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [enabled]); }, [enabled, bandFilter]);
// Render WSPR paths on map // Render WSPR paths on map
useEffect(() => { useEffect(() => {
if (!map || typeof L === 'undefined') return; if (!map || typeof L === 'undefined') return;
// Clear old layers
pathLayers.forEach(layer => { pathLayers.forEach(layer => {
try { map.removeLayer(layer); } catch (e) {} try {
map.removeLayer(layer);
} catch (e) {}
}); });
markerLayers.forEach(layer => { markerLayers.forEach(layer => {
try { map.removeLayer(layer); } catch (e) {} try {
map.removeLayer(layer);
} catch (e) {}
}); });
setPathLayers([]); setPathLayers([]);
setMarkerLayers([]); setMarkerLayers([]);
@ -85,14 +117,13 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
const newPaths = []; const newPaths = [];
const newMarkers = []; const newMarkers = [];
const txStations = new Set(); const txStations = new Set();
const rxStations = new Set(); const rxStations = new Set();
// Limit to most recent 500 spots for performance
const limitedData = wsprData.slice(0, 500); const limitedData = wsprData.slice(0, 500);
limitedData.forEach(spot => { limitedData.forEach(spot => {
// Draw path line from sender to receiver
const path = L.polyline( const path = L.polyline(
[ [
[spot.senderLat, spot.senderLon], [spot.senderLat, spot.senderLon],
@ -106,12 +137,11 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
} }
); );
// Add popup to path
const snrStr = spot.snr !== null ? `${spot.snr} dB` : 'N/A'; 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`; const ageStr = spot.age < 60 ? `${spot.age} min ago` : `${Math.floor(spot.age / 60)}h ago`;
path.bindPopup(` path.bindPopup(`
<div style="font-family: monospace; min-width: 220px;"> <div style="font-family: 'JetBrains Mono', monospace; min-width: 220px;">
<div style="font-size: 14px; font-weight: bold; color: ${getSNRColor(spot.snr)}; margin-bottom: 6px;"> <div style="font-size: 14px; font-weight: bold; color: ${getSNRColor(spot.snr)}; margin-bottom: 6px;">
📡 WSPR Spot 📡 WSPR Spot
</div> </div>
@ -128,10 +158,10 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
path.addTo(map); path.addTo(map);
newPaths.push(path); newPaths.push(path);
// Add transmitter marker
const txKey = `${spot.sender}-${spot.senderGrid}`; const txKey = `${spot.sender}-${spot.senderGrid}`;
if (!txStations.has(txKey)) { if (!txStations.has(txKey)) {
txStations.add(txKey); txStations.add(txKey);
const txMarker = L.circleMarker([spot.senderLat, spot.senderLon], { const txMarker = L.circleMarker([spot.senderLat, spot.senderLon], {
radius: 4, radius: 4,
fillColor: '#ff6600', fillColor: '#ff6600',
@ -140,15 +170,16 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
fillOpacity: opacity * 0.8, fillOpacity: opacity * 0.8,
opacity: opacity opacity: opacity
}); });
txMarker.bindTooltip(`TX: ${spot.sender}`, { permanent: false, direction: 'top' }); txMarker.bindTooltip(`TX: ${spot.sender}`, { permanent: false, direction: 'top' });
txMarker.addTo(map); txMarker.addTo(map);
newMarkers.push(txMarker); newMarkers.push(txMarker);
} }
// Add receiver marker
const rxKey = `${spot.receiver}-${spot.receiverGrid}`; const rxKey = `${spot.receiver}-${spot.receiverGrid}`;
if (!rxStations.has(rxKey)) { if (!rxStations.has(rxKey)) {
rxStations.add(rxKey); rxStations.add(rxKey);
const rxMarker = L.circleMarker([spot.receiverLat, spot.receiverLon], { const rxMarker = L.circleMarker([spot.receiverLat, spot.receiverLon], {
radius: 4, radius: 4,
fillColor: '#0088ff', fillColor: '#0088ff',
@ -157,6 +188,7 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
fillOpacity: opacity * 0.8, fillOpacity: opacity * 0.8,
opacity: opacity opacity: opacity
}); });
rxMarker.bindTooltip(`RX: ${spot.receiver}`, { permanent: false, direction: 'top' }); rxMarker.bindTooltip(`RX: ${spot.receiver}`, { permanent: false, direction: 'top' });
rxMarker.addTo(map); rxMarker.addTo(map);
newMarkers.push(rxMarker); newMarkers.push(rxMarker);
@ -165,19 +197,23 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
setPathLayers(newPaths); setPathLayers(newPaths);
setMarkerLayers(newMarkers); setMarkerLayers(newMarkers);
console.log(`[WSPR Plugin] Rendered ${newPaths.length} paths, ${newMarkers.length} markers`); console.log(`[WSPR Plugin] Rendered ${newPaths.length} paths, ${newMarkers.length} markers`);
return () => { return () => {
newPaths.forEach(layer => { newPaths.forEach(layer => {
try { map.removeLayer(layer); } catch (e) {} try {
map.removeLayer(layer);
} catch (e) {}
}); });
newMarkers.forEach(layer => { newMarkers.forEach(layer => {
try { map.removeLayer(layer); } catch (e) {} try {
map.removeLayer(layer);
} catch (e) {}
}); });
}; };
}, [enabled, wsprData, map, opacity]); }, [enabled, wsprData, map, opacity]);
// Update opacity when it changes
useEffect(() => { useEffect(() => {
pathLayers.forEach(layer => { pathLayers.forEach(layer => {
if (layer.setStyle) { if (layer.setStyle) {

Loading…
Cancel
Save

Powered by TurnKey Linux.