You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
412 lines
13 KiB
412 lines
13 KiB
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(`
|
|
<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;">
|
|
📡 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);
|
|
|
|
const txKey = `${spot.sender}-${spot.senderGrid}`;
|
|
if (!txStations.has(txKey)) {
|
|
txStations.add(txKey);
|
|
|
|
const txMarker = L.circleMarker([sLat, sLon], {
|
|
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);
|
|
}
|
|
|
|
const rxKey = `${spot.receiver}-${spot.receiverGrid}`;
|
|
if (!rxStations.has(rxKey)) {
|
|
rxStations.add(rxKey);
|
|
|
|
const rxMarker = L.circleMarker([rLat, rLon], {
|
|
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);
|
|
|
|
// Add signal strength legend
|
|
if (!legendControl && map) {
|
|
const LegendControl = L.Control.extend({
|
|
options: { position: 'bottomright' },
|
|
onAdd: function() {
|
|
const div = L.DomUtil.create('div', 'wspr-legend');
|
|
div.style.cssText = `
|
|
background: rgba(0, 0, 0, 0.8);
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 11px;
|
|
color: white;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
`;
|
|
div.innerHTML = `
|
|
<div style="font-weight: bold; margin-bottom: 5px; font-size: 12px;">📡 Signal Strength</div>
|
|
<div><span style="color: #00ff00;">●</span> Excellent (> 5 dB)</div>
|
|
<div><span style="color: #ffff00;">●</span> Good (0 to 5 dB)</div>
|
|
<div><span style="color: #ffaa00;">●</span> Moderate (-10 to 0 dB)</div>
|
|
<div><span style="color: #ff6600;">●</span> Weak (-20 to -10 dB)</div>
|
|
<div><span style="color: #ff0000;">●</span> Very Weak (< -20 dB)</div>
|
|
`;
|
|
return div;
|
|
}
|
|
});
|
|
const legend = new LegendControl();
|
|
map.addControl(legend);
|
|
setLegendControl(legend);
|
|
}
|
|
|
|
// Add statistics display
|
|
if (!statsControl && map) {
|
|
const StatsControl = L.Control.extend({
|
|
options: { position: 'topleft' },
|
|
onAdd: function() {
|
|
const div = L.DomUtil.create('div', 'wspr-stats');
|
|
div.style.cssText = `
|
|
background: rgba(0, 0, 0, 0.8);
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 11px;
|
|
color: white;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
`;
|
|
const totalStations = txStations.size + rxStations.size;
|
|
div.innerHTML = `
|
|
<div style="font-weight: bold; margin-bottom: 5px; font-size: 12px;">📊 WSPR Activity</div>
|
|
<div>Propagation Paths: ${newPaths.length}</div>
|
|
<div>TX Stations: ${txStations.size}</div>
|
|
<div>RX Stations: ${rxStations.size}</div>
|
|
<div>Total Stations: ${totalStations}</div>
|
|
<div style="margin-top: 5px; font-size: 10px; opacity: 0.7;">Last 30 minutes</div>
|
|
`;
|
|
return div;
|
|
}
|
|
});
|
|
const stats = new StatsControl();
|
|
map.addControl(stats);
|
|
setStatsControl(stats);
|
|
}
|
|
|
|
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) {}
|
|
});
|
|
if (legendControl && map) {
|
|
try {
|
|
map.removeControl(legendControl);
|
|
} catch (e) {}
|
|
setLegendControl(null);
|
|
}
|
|
if (statsControl && map) {
|
|
try {
|
|
map.removeControl(statsControl);
|
|
} catch (e) {}
|
|
setStatsControl(null);
|
|
}
|
|
};
|
|
}, [enabled, wsprData, map, opacity, legendControl, statsControl]);
|
|
|
|
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,
|
|
legend: legendControl,
|
|
stats: statsControl
|
|
};
|
|
}
|