feat(plugin): Add WSPR Propagation Heatmap plugin

- Added /api/wspr/heatmap endpoint to fetch global WSPR spots via PSK Reporter
- Created useWSPR.js plugin with real-time propagation visualization
- Displays color-coded propagation paths based on SNR (signal strength)
- Shows TX (orange) and RX (blue) station markers with tooltips
- Supports band filtering and 30-minute lookback window
- Auto-refreshes every 5 minutes with caching
- Registered plugin in layerRegistry for Settings panel access

Features:
- Path lines color-coded: red (weak) → yellow → green (strong)
- Line weight varies with signal strength
- Detailed popups showing TX/RX callsigns, frequency, SNR, and age
- Performance-optimized with 500-spot limit
- Compatible with existing plugin architecture (enable/disable, opacity control)

This plugin enables real-time visualization of global HF propagation conditions
by showing WSPR beacon paths and signal strengths across all bands.
pull/82/head
trancen 2 days ago
parent 91e71a2db1
commit 8eb0a5b881

@ -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 = /<receptionReport[^>]*>/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 // SATELLITE TRACKING API
// ============================================ // ============================================

@ -5,11 +5,13 @@
import * as WXRadarPlugin from './layers/useWXRadar.js'; import * as WXRadarPlugin from './layers/useWXRadar.js';
import * as EarthquakesPlugin from './layers/useEarthquakes.js'; import * as EarthquakesPlugin from './layers/useEarthquakes.js';
import * as AuroraPlugin from './layers/useAurora.js'; import * as AuroraPlugin from './layers/useAurora.js';
import * as WSPRPlugin from './layers/useWSPR.js';
const layerPlugins = [ const layerPlugins = [
WXRadarPlugin, WXRadarPlugin,
EarthquakesPlugin, EarthquakesPlugin,
AuroraPlugin, AuroraPlugin,
WSPRPlugin,
]; ];
export function getAllLayers() { export function getAllLayers() {

@ -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(`
<div style="font-family: monospace; min-width: 220px;">
<div style="font-size: 14px; font-weight: bold; color: ${getSNRColor(spot.snr)}; margin-bottom: 6px;">
📡 WSPR Spot
</div>
<table style="font-size: 11px; width: 100%;">
<tr><td><b>TX:</b></td><td>${spot.sender} (${spot.senderGrid})</td></tr>
<tr><td><b>RX:</b></td><td>${spot.receiver} (${spot.receiverGrid})</td></tr>
<tr><td><b>Freq:</b></td><td>${spot.freqMHz} MHz (${spot.band})</td></tr>
<tr><td><b>SNR:</b></td><td style="color: ${getSNRColor(spot.snr)}; font-weight: bold;">${snrStr}</td></tr>
<tr><td><b>Time:</b></td><td>${ageStr}</td></tr>
</table>
</div>
`);
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
};
}
Loading…
Cancel
Save

Powered by TurnKey Linux.