diff --git a/src/plugins/layers/useWSPR.js b/src/plugins/layers/useWSPR.js
index 280ae2a..ec768e2 100644
--- a/src/plugins/layers/useWSPR.js
+++ b/src/plugins/layers/useWSPR.js
@@ -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:
- * - Path lines between transmitters and receivers
+ * - 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
*
@@ -16,12 +19,12 @@ import { useState, useEffect } from 'react';
export const metadata = {
id: 'wspr',
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: '📡',
category: 'propagation',
defaultEnabled: false,
defaultOpacity: 0.7,
- version: '1.0.0'
+ version: '1.1.0'
};
// Convert grid square to lat/lon
@@ -67,11 +70,53 @@ function getLineWeight(snr) {
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 }) {
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(() => {
@@ -124,18 +169,21 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
const limitedData = wsprData.slice(0, 500);
limitedData.forEach(spot => {
- 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'
- }
+ // Calculate great circle path for curved line
+ const pathCoords = getGreatCirclePath(
+ spot.senderLat,
+ spot.senderLon,
+ spot.receiverLat,
+ spot.receiverLon,
+ 30 // Number of points for smooth curve
);
+
+ 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`;
@@ -198,6 +246,69 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
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 = `
+
📡 Signal Strength
+ ● Excellent (> 5 dB)
+ ● Good (0 to 5 dB)
+ ● Moderate (-10 to 0 dB)
+ ● Weak (-20 to -10 dB)
+ ● Very Weak (< -20 dB)
+ `;
+ 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 = `
+ 📊 WSPR Activity
+ Propagation Paths: ${newPaths.length}
+ TX Stations: ${txStations.size}
+ RX Stations: ${rxStations.size}
+ Total Stations: ${totalStations}
+ Last 30 minutes
+ `;
+ return div;
+ }
+ });
+ const stats = new StatsControl();
+ map.addControl(stats);
+ setStatsControl(stats);
+ }
+
console.log(`[WSPR Plugin] Rendered ${newPaths.length} paths, ${newMarkers.length} markers`);
return () => {
@@ -211,8 +322,20 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
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]);
+ }, [enabled, wsprData, map, opacity, legendControl, statsControl]);
useEffect(() => {
pathLayers.forEach(layer => {
@@ -233,6 +356,8 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
return {
paths: pathLayers,
markers: markerLayers,
- spotCount: wsprData.length
+ spotCount: wsprData.length,
+ legend: legendControl,
+ stats: statsControl
};
}
diff --git a/src/plugins/layers/wspr/README.md b/src/plugins/layers/wspr/README.md
new file mode 100644
index 0000000..1b3598c
--- /dev/null
+++ b/src/plugins/layers/wspr/README.md
@@ -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! 📡*