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! 📡*