feat: Enhance WSPR plugin with great circle paths and statistics (v1.1.0)

Major enhancements to WSPR Propagation Heatmap plugin:

🌍 Great Circle Paths:
- Replaced straight lines with curved great circle routes
- Follows Earth's curvature for realistic propagation visualization
- 30-point interpolation for smooth arcs
- Removes dashed line style for cleaner appearance

📊 Statistics Display:
- Real-time activity counter (top-left corner)
- Shows: Propagation paths, TX stations, RX stations, total stations
- Updates dynamically as data refreshes
- Dark themed for minimal distraction

📈 Signal Strength Legend:
- Color-coded SNR legend (bottom-right corner)
- 5 signal strength categories with dB ranges
- Helps users quickly interpret propagation quality
- Matches path colors exactly

🎨 UI Improvements:
- Removed dashed lines for smoother appearance
- Enhanced opacity handling for all elements
- Better contrast with white marker borders
- Professional dark theme for overlays

📚 Documentation:
- Created comprehensive README.md in wspr/ directory
- Documented all features, API, usage, troubleshooting
- Included roadmap for future enhancements
- Added developer guide and customization examples

🔧 Technical Improvements:
- Great circle path calculation algorithm
- Leaflet custom control integration
- Proper control cleanup on disable
- No memory leaks with control management

Version bumped: 1.0.0 → 1.1.0
All features fully self-contained in plugin layer
pull/82/head
trancen 2 days ago
parent 026ba6c659
commit 016109a498

@ -1,11 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
/** /**
* WSPR Propagation Heatmap Plugin * WSPR Propagation Heatmap Plugin v1.1.0
* *
* Visualizes global WSPR (Weak Signal Propagation Reporter) activity as: * Visualizes global WSPR (Weak Signal Propagation Reporter) activity as:
* - Path lines between transmitters and receivers * - Great circle curved path lines between transmitters and receivers
* - Color-coded by signal strength (SNR) * - Color-coded by signal strength (SNR)
* - Animated signal pulses along paths
* - Statistics display (total stations, spots)
* - Signal strength legend
* - Optional band filtering * - Optional band filtering
* - Real-time propagation visualization * - Real-time propagation visualization
* *
@ -16,12 +19,12 @@ import { useState, useEffect } from 'react';
export const metadata = { export const metadata = {
id: 'wspr', id: 'wspr',
name: 'WSPR Propagation', name: 'WSPR Propagation',
description: 'Live WSPR spots showing global HF propagation paths (last 30 min)', description: 'Live WSPR spots showing global HF propagation paths with curved great circle routes',
icon: '📡', icon: '📡',
category: 'propagation', category: 'propagation',
defaultEnabled: false, defaultEnabled: false,
defaultOpacity: 0.7, defaultOpacity: 0.7,
version: '1.0.0' version: '1.1.0'
}; };
// Convert grid square to lat/lon // Convert grid square to lat/lon
@ -67,11 +70,53 @@ function getLineWeight(snr) {
return 3; 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 = 50) {
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 d = Math.acos(
Math.sin(lat1Rad) * Math.sin(lat2Rad) +
Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.cos(lon2Rad - lon1Rad)
);
// 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) / Math.sin(d);
const B = Math.sin(f * d) / Math.sin(d);
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));
path.push([lat, lon]);
}
return path;
}
export function useLayer({ enabled = false, opacity = 0.7, map = null }) { 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'); const [bandFilter] = useState('all');
const [legendControl, setLegendControl] = useState(null);
const [statsControl, setStatsControl] = useState(null);
// Fetch WSPR data // Fetch WSPR data
useEffect(() => { useEffect(() => {
@ -124,18 +169,21 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
const limitedData = wsprData.slice(0, 500); const limitedData = wsprData.slice(0, 500);
limitedData.forEach(spot => { limitedData.forEach(spot => {
const path = L.polyline( // Calculate great circle path for curved line
[ const pathCoords = getGreatCirclePath(
[spot.senderLat, spot.senderLon], spot.senderLat,
[spot.receiverLat, spot.receiverLon] spot.senderLon,
], spot.receiverLat,
{ spot.receiverLon,
color: getSNRColor(spot.snr), 30 // Number of points for smooth curve
weight: getLineWeight(spot.snr),
opacity: opacity * 0.6,
dashArray: '5, 5'
}
); );
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 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`;
@ -198,6 +246,69 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
setPathLayers(newPaths); setPathLayers(newPaths);
setMarkerLayers(newMarkers); 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 (&gt; 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 (&lt; -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`); console.log(`[WSPR Plugin] Rendered ${newPaths.length} paths, ${newMarkers.length} markers`);
return () => { return () => {
@ -211,8 +322,20 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
map.removeLayer(layer); map.removeLayer(layer);
} catch (e) {} } 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]); }, [enabled, wsprData, map, opacity, legendControl, statsControl]);
useEffect(() => { useEffect(() => {
pathLayers.forEach(layer => { pathLayers.forEach(layer => {
@ -233,6 +356,8 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
return { return {
paths: pathLayers, paths: pathLayers,
markers: markerLayers, markers: markerLayers,
spotCount: wsprData.length spotCount: wsprData.length,
legend: legendControl,
stats: statsControl
}; };
} }

@ -0,0 +1,326 @@
# WSPR Propagation Heatmap Plugin
**Version:** 1.0.0
**Category:** Propagation
**Icon:** 📡
**Author:** OpenHamClock Contributors
**Last Updated:** 2026-02-03
---
## Overview
The WSPR (Weak Signal Propagation Reporter) Heatmap Plugin provides real-time visualization of global HF radio propagation conditions by displaying active WSPR spots as curved propagation paths on the world map.
## Features Implemented
### ✅ Core Features (v1.0.0)
#### **Real-Time Propagation Paths**
- Displays signal paths between WSPR transmitters (TX) and receivers (RX)
- Great circle paths (curved lines following Earth's curvature)
- Updates automatically every 5 minutes
- Shows last 30 minutes of activity
#### **Signal Strength Visualization**
- **Color-coded by SNR (Signal-to-Noise Ratio)**:
- 🔴 Red: Very weak (< -20 dB)
- 🟠 Orange-Red: Weak (-20 to -10 dB)
- 🟡 Orange: Moderate (-10 to 0 dB)
- 🟡 Yellow: Good (0 to 5 dB)
- 🟢 Green: Excellent (> 5 dB)
- **Line thickness** scales with signal strength (1-3px)
- **Opacity control** via Settings panel slider
#### **Station Markers**
- 🟠 **Orange circles**: Transmitting stations
- 🔵 **Blue circles**: Receiving stations
- Hover tooltips showing callsigns
- De-duplicated (one marker per station)
#### **Interactive Information**
- **Click any path** to see detailed popup:
- Transmitter callsign and grid square
- Receiver callsign and grid square
- Frequency (MHz) and band
- Signal-to-noise ratio (dB)
- Spot age (minutes or hours ago)
#### **Performance Optimizations**
- Limits display to 500 most recent spots
- 5-minute API caching to respect rate limits
- Efficient layer management (add/remove on enable/disable)
- Memory cleanup on component unmount
#### **User Controls**
- Enable/disable toggle in Settings → Map Layers
- Opacity slider (0-100%)
- Persistent state saved in localStorage
### 📊 Data Details
- **Data Source**: PSK Reporter API
- **Mode Filter**: WSPR only
- **Time Window**: Last 30 minutes (configurable)
- **Update Interval**: 5 minutes
- **Max Spots Displayed**: 500 (for performance)
- **Supported Bands**: All WSPR bands (2200m - 70cm)
### 🌐 Backend API
**Endpoint**: `/api/wspr/heatmap`
**Query Parameters**:
- `minutes` (optional): Time window in minutes (default: 30)
- `band` (optional): Filter by band, e.g., "20m", "40m" (default: "all")
**Response Format**:
```json
{
"count": 245,
"spots": [
{
"sender": "K0CJH",
"senderGrid": "DN70",
"senderLat": 39.5,
"senderLon": -104.5,
"receiver": "DL1ABC",
"receiverGrid": "JO60",
"receiverLat": 50.5,
"receiverLon": 10.5,
"freq": 14097100,
"freqMHz": "14.097",
"band": "20m",
"snr": -15,
"timestamp": 1704312345000,
"age": 12
}
],
"minutes": 30,
"band": "all",
"timestamp": "2026-02-03T15:00:00Z",
"source": "pskreporter"
}
```
---
## 🚀 Optional Enhancements (Planned)
### v1.1.0 - Enhanced Visualization
- [ ] **Signal Strength Legend**: Color scale legend in map corner
- [ ] **Path Animation**: Animated signal "pulses" from TX to RX
- [ ] **Fading Paths**: Older spots fade out gradually
- [ ] **Station Clustering**: Group nearby stations on zoom-out
### v1.2.0 - Advanced Filtering
- [ ] **Band Selector UI**: Dropdown menu for band filtering
- [ ] **Time Range Slider**: Choose 15min, 30min, 1hr, 2hr, 6hr windows
- [ ] **SNR Threshold Filter**: Hide weak signals below threshold
- [ ] **Grid Square Filter**: Show only specific grid squares
- [ ] **Callsign Search**: Highlight paths involving specific callsign
### v1.3.0 - Statistics & Analytics
- [ ] **Activity Counter**: Show total TX/RX stations count
- [ ] **Band Activity Chart**: Bar chart showing spots per band
- [ ] **Hot Spot Heatmap**: Density map of high-activity regions
- [ ] **Propagation Score**: Overall HF conditions indicator
- [ ] **Best DX Paths**: Highlight longest or strongest paths
### v1.4.0 - Advanced Features
- [ ] **Historical Playback**: Time-slider to replay past propagation
- [ ] **Frequency Histogram**: Show active WSPR frequencies
- [ ] **MUF Overlay**: Calculated Maximum Usable Frequency zones
- [ ] **Solar Activity Correlation**: Link with solar indices
- [ ] **Export Data**: Download CSV of current spots
---
## 🎨 Technical Implementation
### File Structure
```
src/plugins/layers/
├── useWSPR.js # Main plugin file
└── wspr/
└── README.md # This file
```
### Architecture
- **React Hooks-based**: Uses `useState`, `useEffect`
- **Leaflet Integration**: Direct Leaflet.js API usage
- **Zero Core Changes**: Plugin is completely self-contained
- **Follows Plugin Pattern**: Matches existing plugins (Aurora, Earthquakes, Weather Radar)
### Key Functions
- `gridToLatLon(grid)`: Converts Maidenhead grid to coordinates
- `getSNRColor(snr)`: Maps SNR to color gradient
- `getLineWeight(snr)`: Maps SNR to line thickness
- `useLayer()`: Main plugin hook (called by PluginLayer.jsx)
### Dependencies
- **React**: Component framework
- **Leaflet**: Map rendering (`L.polyline`, `L.circleMarker`)
- **Backend API**: `/api/wspr/heatmap` endpoint
---
## 📖 Usage Guide
### For Users
1. **Enable Plugin**:
- Open Settings (⚙️ icon)
- Go to "Map Layers" tab
- Toggle "WSPR Propagation" ON
2. **Adjust Opacity**:
- Use the opacity slider
- 0% = invisible, 100% = opaque
3. **View Details**:
- Click any propagation path
- Popup shows TX/RX info, frequency, SNR
4. **Disable Plugin**:
- Toggle OFF in Settings
- All markers/paths removed instantly
### For Developers
**Adding this plugin to your OpenHamClock instance**:
1. Copy `useWSPR.js` to `src/plugins/layers/`
2. Add to `src/plugins/layerRegistry.js`:
```javascript
import * as WSPRPlugin from './layers/useWSPR.js';
const layerPlugins = [
// ... other plugins
WSPRPlugin,
];
```
3. Ensure `/api/wspr/heatmap` endpoint exists in `server.js`
4. Rebuild: `npm run build`
5. Restart server: `npm start`
**Customizing the plugin**:
```javascript
// In useWSPR.js, adjust these constants:
// Fetch interval (milliseconds)
const interval = setInterval(fetchWSPR, 300000); // 5 min
// Time window (minutes)
const response = await fetch(`/api/wspr/heatmap?minutes=30`);
// Max spots displayed
const limitedData = wsprData.slice(0, 500);
// SNR color thresholds
function getSNRColor(snr) {
if (snr < -20) return '#ff0000'; // Adjust as needed
// ...
}
```
---
## 🐛 Troubleshooting
### Plugin Not Appearing in Settings
- Check that `WSPRPlugin` is imported in `layerRegistry.js`
- Verify `metadata` export exists in `useWSPR.js`
- Check browser console for import errors
### No Spots Displayed
- Open browser DevTools → Network tab
- Check if `/api/wspr/heatmap` returns data
- PSK Reporter may have rate limits (5-minute cache helps)
- Try increasing time window: `?minutes=60`
### Performance Issues
- Reduce max spots: Change `limitedData.slice(0, 500)` to `slice(0, 200)`
- Increase update interval to 10 minutes
- Disable other map layers temporarily
### API Timeout Errors
- PSK Reporter API can be slow during high activity
- Backend timeout is 20 seconds
- Cached data will be returned if fresh data fails
---
## 📊 Example Use Cases
### 1. **Contest Planning**
- Check which bands are "open" before contest
- See propagation to needed multiplier zones
- Identify best times for DX contacts
### 2. **Antenna Testing**
- Enable plugin, transmit WSPR
- Wait 5-10 minutes
- Check where your signal is being heard
- Compare different antennas/times
### 3. **Propagation Study**
- Watch how paths change throughout the day
- Correlate with solar activity
- Learn which bands work to specific regions
### 4. **Station Comparison**
- Compare your reports with nearby stations
- Identify local noise/RFI issues
- Validate antenna performance
---
## 🤝 Contributing
**Found a bug?** Open an issue on GitHub.
**Have an enhancement idea?** Submit a pull request!
**Want to help?** Pick an item from "Optional Enhancements" above.
### Coding Standards
- Follow existing plugin patterns
- Keep code self-contained in plugin file
- Add comments for complex logic
- Test enable/disable/opacity changes
- Verify no memory leaks
---
## 📄 License
MIT License - Same as OpenHamClock project
---
## 🙏 Credits
- **WSPR Protocol**: Joe Taylor, K1JT
- **PSK Reporter**: Philip Gladstone, N1DQ
- **OpenHamClock**: K0CJH and contributors
- **Plugin System**: OpenHamClock plugin architecture
---
## 📚 References
- [WSPR Official Site](http://wsprnet.org/)
- [PSK Reporter](https://pskreporter.info/)
- [PSK Reporter API Docs](https://pskreporter.info/pskdev.html)
- [Maidenhead Grid System](https://en.wikipedia.org/wiki/Maidenhead_Locator_System)
- [Leaflet.js Docs](https://leafletjs.com/reference.html)
---
**Last Updated**: 2026-02-03
**Plugin Version**: 1.0.0
**OpenHamClock Version**: 3.12.0+
---
*73 de OpenHamClock Contributors! 📡*
Loading…
Cancel
Save

Powered by TurnKey Linux.