diff --git a/src/plugins/layers/useWSPR.js b/src/plugins/layers/useWSPR.js
index 5bdae91..7a9a7af 100644
--- a/src/plugins/layers/useWSPR.js
+++ b/src/plugins/layers/useWSPR.js
@@ -151,6 +151,79 @@ function calculatePropagationScore(spots) {
return Math.round(snrScore + countScore + strongScore);
}
+// Make control panel draggable and save position
+function makeDraggable(element, storageKey) {
+ if (!element) return;
+
+ // Load saved position
+ const saved = localStorage.getItem(storageKey);
+ if (saved) {
+ try {
+ const { top, left } = JSON.parse(saved);
+ element.style.position = 'fixed';
+ element.style.top = top + 'px';
+ element.style.left = left + 'px';
+ element.style.right = 'auto';
+ element.style.bottom = 'auto';
+ } catch (e) {}
+ } else {
+ // Convert from Leaflet control position to fixed
+ const rect = element.getBoundingClientRect();
+ element.style.position = 'fixed';
+ element.style.top = rect.top + 'px';
+ element.style.left = rect.left + 'px';
+ element.style.right = 'auto';
+ element.style.bottom = 'auto';
+ }
+
+ // Add drag handle
+ element.style.cursor = 'move';
+ element.title = 'Drag to reposition';
+
+ let isDragging = false;
+ let startX, startY, startLeft, startTop;
+
+ element.addEventListener('mousedown', function(e) {
+ // Only allow dragging from empty areas (not inputs/selects)
+ if (e.target.tagName === 'SELECT' || e.target.tagName === 'INPUT' || e.target.tagName === 'LABEL') {
+ return;
+ }
+
+ isDragging = true;
+ startX = e.clientX;
+ startY = e.clientY;
+ startLeft = element.offsetLeft;
+ startTop = element.offsetTop;
+
+ element.style.opacity = '0.8';
+ e.preventDefault();
+ });
+
+ document.addEventListener('mousemove', function(e) {
+ if (!isDragging) return;
+
+ const dx = e.clientX - startX;
+ const dy = e.clientY - startY;
+
+ element.style.left = (startLeft + dx) + 'px';
+ element.style.top = (startTop + dy) + 'px';
+ });
+
+ document.addEventListener('mouseup', function(e) {
+ if (isDragging) {
+ isDragging = false;
+ element.style.opacity = '1';
+
+ // Save position
+ const position = {
+ top: element.offsetTop,
+ left: element.offsetLeft
+ };
+ localStorage.setItem(storageKey, JSON.stringify(position));
+ }
+ });
+}
+
export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
const [pathLayers, setPathLayers] = useState([]);
const [markerLayers, setMarkerLayers] = useState([]);
@@ -279,6 +352,14 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
map.addControl(control);
setFilterControl(control);
+ // Make control draggable after it's added to DOM
+ setTimeout(() => {
+ const container = document.querySelector('.wspr-filter-control');
+ if (container) {
+ makeDraggable(container, 'wspr-filter-position');
+ }
+ }, 150);
+
// Add event listeners after control is added
setTimeout(() => {
const bandSelect = document.getElementById('wspr-band-filter');
@@ -297,7 +378,10 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
});
}
if (animCheck) animCheck.addEventListener('change', (e) => setShowAnimation(e.target.checked));
- if (heatCheck) heatCheck.addEventListener('change', (e) => setShowHeatmap(e.target.checked));
+ if (heatCheck) heatCheck.addEventListener('change', (e) => {
+ console.log('[WSPR] Heatmap toggle:', e.target.checked);
+ setShowHeatmap(e.target.checked);
+ });
}, 100);
}, [enabled, map, filterControl]);
@@ -479,6 +563,12 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
map.addControl(stats);
setStatsControl(stats);
+ // Make stats draggable
+ setTimeout(() => {
+ const container = document.querySelector('.wspr-stats');
+ if (container) makeDraggable(container, 'wspr-stats-position');
+ }, 150);
+
// Add legend
if (!legendControl && map) {
const LegendControl = L.Control.extend({
@@ -511,6 +601,12 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
const legend = new LegendControl();
map.addControl(legend);
setLegendControl(legend);
+
+ // Make legend draggable
+ setTimeout(() => {
+ const container = document.querySelector('.wspr-legend');
+ if (container) makeDraggable(container, 'wspr-legend-position');
+ }, 150);
}
// Add band activity chart
@@ -565,6 +661,12 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
const chart = new ChartControl();
map.addControl(chart);
setChartControl(chart);
+
+ // Make chart draggable
+ setTimeout(() => {
+ const container = document.querySelector('.wspr-chart');
+ if (container) makeDraggable(container, 'wspr-chart-position');
+ }, 150);
}
console.log(`[WSPR Plugin] Rendered ${newPaths.length} paths, ${newMarkers.length} markers, ${bestPaths.length} best DX`);
@@ -579,6 +681,109 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
};
}, [enabled, wsprData, map, opacity, snrThreshold, showAnimation, timeWindow, legendControl, statsControl, chartControl]);
+ // Render heatmap overlay (v1.4.0)
+ useEffect(() => {
+ if (!map || typeof L === 'undefined') return;
+
+ // Remove existing heatmap
+ if (heatmapLayer && map) {
+ try {
+ map.removeLayer(heatmapLayer);
+ } catch (e) {}
+ setHeatmapLayer(null);
+ }
+
+ if (!enabled || !showHeatmap || wsprData.length === 0) return;
+
+ console.log('[WSPR] Rendering heatmap with', wsprData.length, 'spots');
+
+ // Create heatmap circles for all TX and RX stations
+ const heatPoints = [];
+ const stationCounts = {};
+
+ // Filter by SNR threshold
+ const filteredData = wsprData.filter(spot => (spot.snr || -30) >= snrThreshold);
+
+ filteredData.forEach(spot => {
+ if (!spot.senderLat || !spot.senderLon || !spot.receiverLat || !spot.receiverLon) return;
+
+ 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)) return;
+
+ // Count activity at each location
+ const txKey = `${sLat.toFixed(1)},${sLon.toFixed(1)}`;
+ const rxKey = `${rLat.toFixed(1)},${rLon.toFixed(1)}`;
+
+ stationCounts[txKey] = (stationCounts[txKey] || 0) + 1;
+ stationCounts[rxKey] = (stationCounts[rxKey] || 0) + 1;
+
+ heatPoints.push({ lat: sLat, lon: sLon, key: txKey });
+ heatPoints.push({ lat: rLat, lon: rLon, key: rxKey });
+ });
+
+ // Create gradient circles for heatmap
+ const heatCircles = [];
+ const uniquePoints = {};
+
+ heatPoints.forEach(point => {
+ if (!uniquePoints[point.key]) {
+ uniquePoints[point.key] = { lat: point.lat, lon: point.lon, count: stationCounts[point.key] };
+ }
+ });
+
+ Object.values(uniquePoints).forEach(point => {
+ const intensity = Math.min(point.count / 10, 1); // Normalize to 0-1
+ const radius = 20 + (intensity * 30); // 20-50 pixels
+ const fillOpacity = 0.3 + (intensity * 0.4); // 0.3-0.7
+
+ // Color based on activity level
+ let color;
+ if (intensity > 0.7) color = '#ff0000'; // Red - very hot
+ else if (intensity > 0.5) color = '#ff6600'; // Orange - hot
+ else if (intensity > 0.3) color = '#ffaa00'; // Yellow - warm
+ else color = '#00aaff'; // Blue - cool
+
+ const circle = L.circle([point.lat, point.lon], {
+ radius: radius * 50000, // Convert to meters for Leaflet
+ fillColor: color,
+ fillOpacity: fillOpacity * opacity,
+ color: color,
+ weight: 0,
+ opacity: 0
+ });
+
+ circle.bindPopup(`
+
+ 🔥 Activity Hot Spot
+ Stations: ${point.count}
+ Lat: ${point.lat.toFixed(2)}
+ Lon: ${point.lon.toFixed(2)}
+
+ `);
+
+ circle.addTo(map);
+ heatCircles.push(circle);
+ });
+
+ // Store as layer group
+ const heatGroup = L.layerGroup(heatCircles);
+ setHeatmapLayer(heatGroup);
+
+ console.log(`[WSPR] Heatmap rendered with ${Object.keys(uniquePoints).length} hot spots`);
+
+ return () => {
+ heatCircles.forEach(circle => {
+ try {
+ map.removeLayer(circle);
+ } catch (e) {}
+ });
+ };
+ }, [enabled, showHeatmap, wsprData, map, opacity, snrThreshold, heatmapLayer]);
+
// Cleanup controls on disable
useEffect(() => {
if (!enabled && map) {
diff --git a/src/plugins/layers/useWSPR.js.backup b/src/plugins/layers/useWSPR.js.backup
new file mode 100644
index 0000000..5bdae91
--- /dev/null
+++ b/src/plugins/layers/useWSPR.js.backup
@@ -0,0 +1,624 @@
+import { useState, useEffect, useRef } from 'react';
+
+/**
+ * WSPR Propagation Heatmap Plugin v1.3.0
+ *
+ * Advanced Features:
+ * - Great circle curved path lines between transmitters and receivers
+ * - Color-coded by signal strength (SNR)
+ * - Animated signal pulses along paths (v1.3.0)
+ * - Band selector dropdown (v1.2.0)
+ * - Time range slider (15min - 6hr) (v1.2.0)
+ * - SNR threshold filter (v1.2.0)
+ * - Hot spot density heatmap (v1.3.0)
+ * - Band activity chart (v1.3.0)
+ * - Propagation score indicator (v1.3.0)
+ * - Best DX paths highlighting (v1.3.0)
+ * - Statistics display (total stations, spots)
+ * - Signal strength legend
+ *
+ * Data source: PSK Reporter API (WSPR mode spots)
+ * Update interval: 5 minutes
+ */
+
+export const metadata = {
+ id: 'wspr',
+ name: 'WSPR Propagation',
+ description: 'Advanced WSPR propagation visualization with filters, analytics, and heatmaps',
+ icon: '📡',
+ category: 'propagation',
+ defaultEnabled: false,
+ defaultOpacity: 0.7,
+ version: '1.3.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
+function getGreatCirclePath(lat1, lon1, lat2, lon2, numPoints = 30) {
+ // Validate input coordinates
+ if (!isFinite(lat1) || !isFinite(lon1) || !isFinite(lat2) || !isFinite(lon2)) {
+ return [[lat1, lon1], [lat2, lon2]];
+ }
+
+ // Check if points are very close (less than 0.5 degree)
+ const deltaLat = Math.abs(lat2 - lat1);
+ const deltaLon = Math.abs(lon2 - lon1);
+ if (deltaLat < 0.5 && deltaLon < 0.5) {
+ return [[lat1, lon1], [lat2, lon2]];
+ }
+
+ const path = [];
+
+ 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);
+
+ const cosD = Math.sin(lat1Rad) * Math.sin(lat2Rad) +
+ Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.cos(lon2Rad - lon1Rad);
+
+ const d = Math.acos(Math.max(-1, Math.min(1, cosD)));
+
+ if (d < 0.01 || Math.abs(d - Math.PI) < 0.01) {
+ return [[lat1, lon1], [lat2, lon2]];
+ }
+
+ const sinD = Math.sin(d);
+
+ 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));
+
+ if (isFinite(lat) && isFinite(lon)) {
+ path.push([lat, lon]);
+ }
+ }
+
+ if (path.length < 2) {
+ return [[lat1, lon1], [lat2, lon2]];
+ }
+
+ return path;
+}
+
+// Calculate propagation score (0-100)
+function calculatePropagationScore(spots) {
+ if (!spots || spots.length === 0) return 0;
+
+ const avgSNR = spots.reduce((sum, s) => sum + (s.snr || -20), 0) / spots.length;
+ const pathCount = spots.length;
+ const strongSignals = spots.filter(s => s.snr > 0).length;
+
+ // Score based on: average SNR (40%), path count (30%), strong signal ratio (30%)
+ const snrScore = Math.max(0, Math.min(100, ((avgSNR + 20) / 25) * 40));
+ const countScore = Math.min(30, (pathCount / 100) * 30);
+ const strongScore = (strongSignals / pathCount) * 30;
+
+ return Math.round(snrScore + countScore + strongScore);
+}
+
+export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
+ const [pathLayers, setPathLayers] = useState([]);
+ const [markerLayers, setMarkerLayers] = useState([]);
+ const [heatmapLayer, setHeatmapLayer] = useState(null);
+ const [wsprData, setWsprData] = useState([]);
+
+ // v1.2.0 - Advanced Filters
+ const [bandFilter, setBandFilter] = useState('all');
+ const [timeWindow, setTimeWindow] = useState(30); // minutes
+ const [snrThreshold, setSNRThreshold] = useState(-30); // dB
+ const [showAnimation, setShowAnimation] = useState(true);
+ const [showHeatmap, setShowHeatmap] = useState(false);
+
+ // UI Controls
+ const [legendControl, setLegendControl] = useState(null);
+ const [statsControl, setStatsControl] = useState(null);
+ const [filterControl, setFilterControl] = useState(null);
+ const [chartControl, setChartControl] = useState(null);
+
+ const animationFrameRef = useRef(null);
+
+ // Fetch WSPR data with dynamic time window and band filter
+ useEffect(() => {
+ if (!enabled) return;
+
+ const fetchWSPR = async () => {
+ try {
+ const response = await fetch(`/api/wspr/heatmap?minutes=${timeWindow}&band=${bandFilter}`);
+ if (response.ok) {
+ const data = await response.json();
+ setWsprData(data.spots || []);
+ console.log(`[WSPR Plugin] Loaded ${data.spots?.length || 0} spots (${timeWindow}min, band: ${bandFilter})`);
+ }
+ } catch (err) {
+ console.error('WSPR data fetch error:', err);
+ }
+ };
+
+ fetchWSPR();
+ const interval = setInterval(fetchWSPR, 300000);
+
+ return () => clearInterval(interval);
+ }, [enabled, bandFilter, timeWindow]);
+
+ // Create filter control panel (v1.2.0)
+ useEffect(() => {
+ if (!enabled || !map || filterControl) return;
+
+ const FilterControl = L.Control.extend({
+ options: { position: 'topright' },
+ onAdd: function() {
+ const container = L.DomUtil.create('div', 'wspr-filter-control');
+ container.style.cssText = `
+ background: rgba(0, 0, 0, 0.9);
+ padding: 12px;
+ border-radius: 5px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 11px;
+ color: white;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
+ min-width: 180px;
+ `;
+
+ container.innerHTML = `
+ 🎛️ Filters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // Prevent map events from propagating
+ L.DomEvent.disableClickPropagation(container);
+ L.DomEvent.disableScrollPropagation(container);
+
+ return container;
+ }
+ });
+
+ const control = new FilterControl();
+ map.addControl(control);
+ setFilterControl(control);
+
+ // Add event listeners after control is added
+ setTimeout(() => {
+ const bandSelect = document.getElementById('wspr-band-filter');
+ const timeSelect = document.getElementById('wspr-time-filter');
+ const snrSlider = document.getElementById('wspr-snr-filter');
+ const snrValue = document.getElementById('snr-value');
+ const animCheck = document.getElementById('wspr-animation');
+ const heatCheck = document.getElementById('wspr-heatmap');
+
+ if (bandSelect) bandSelect.addEventListener('change', (e) => setBandFilter(e.target.value));
+ if (timeSelect) timeSelect.addEventListener('change', (e) => setTimeWindow(parseInt(e.target.value)));
+ if (snrSlider) {
+ snrSlider.addEventListener('input', (e) => {
+ setSNRThreshold(parseInt(e.target.value));
+ if (snrValue) snrValue.textContent = e.target.value;
+ });
+ }
+ if (animCheck) animCheck.addEventListener('change', (e) => setShowAnimation(e.target.checked));
+ if (heatCheck) heatCheck.addEventListener('change', (e) => setShowHeatmap(e.target.checked));
+ }, 100);
+
+ }, [enabled, map, filterControl]);
+
+ // Render WSPR paths and markers
+ 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();
+
+ // Filter by SNR threshold
+ const filteredData = wsprData.filter(spot => (spot.snr || -30) >= snrThreshold);
+ const limitedData = filteredData.slice(0, 500);
+
+ // Find best DX paths (longest distance, good SNR)
+ const bestPaths = limitedData
+ .map(spot => {
+ const dist = Math.sqrt(
+ Math.pow(spot.receiverLat - spot.senderLat, 2) +
+ Math.pow(spot.receiverLon - spot.senderLon, 2)
+ );
+ return { ...spot, distance: dist };
+ })
+ .filter(s => s.snr > 0)
+ .sort((a, b) => b.distance - a.distance)
+ .slice(0, 10);
+
+ const bestPathSet = new Set(bestPaths.map(p => `${p.sender}-${p.receiver}`));
+
+ limitedData.forEach(spot => {
+ // Validate coordinates
+ if (!spot.senderLat || !spot.senderLon || !spot.receiverLat || !spot.receiverLon) {
+ return;
+ }
+
+ 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)) {
+ return;
+ }
+
+ // Calculate great circle path
+ const pathCoords = getGreatCirclePath(sLat, sLon, rLat, rLon, 30);
+
+ if (!pathCoords || pathCoords.length < 2) {
+ return;
+ }
+
+ // Check if this is a best DX path
+ const isBestPath = bestPathSet.has(`${spot.sender}-${spot.receiver}`);
+
+ const path = L.polyline(pathCoords, {
+ color: isBestPath ? '#00ffff' : getSNRColor(spot.snr),
+ weight: isBestPath ? 4 : getLineWeight(spot.snr),
+ opacity: opacity * (isBestPath ? 0.9 : 0.6),
+ smoothFactor: 1,
+ className: showAnimation ? 'wspr-animated-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(`
+
+
+ ${isBestPath ? '⭐ Best DX Path' : '📡 WSPR Spot'}
+
+
+ | TX: | ${spot.sender} (${spot.senderGrid}) |
+ | RX: | ${spot.receiver} (${spot.receiverGrid}) |
+ | Freq: | ${spot.freqMHz} MHz (${spot.band}) |
+ | SNR: | ${snrStr} |
+ | Time: | ${ageStr} |
+
+
+ `);
+
+ path.addTo(map);
+ newPaths.push(path);
+
+ // Add markers
+ 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);
+
+ // Update statistics control
+ if (statsControl && map) {
+ try {
+ map.removeControl(statsControl);
+ } catch (e) {}
+ setStatsControl(null);
+ }
+
+ 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.9);
+ padding: 12px;
+ 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 propScore = calculatePropagationScore(limitedData);
+ const scoreColor = propScore > 70 ? '#00ff00' : propScore > 40 ? '#ffaa00' : '#ff6600';
+ const totalStations = txStations.size + rxStations.size;
+
+ div.innerHTML = `
+ 📊 WSPR Activity
+
+
Propagation Score
+
${propScore}/100
+
+ Paths: ${newPaths.length}
+ TX Stations: ${txStations.size}
+ RX Stations: ${rxStations.size}
+ Total: ${totalStations}
+ Last ${timeWindow} min
+ `;
+ return div;
+ }
+ });
+
+ const stats = new StatsControl();
+ map.addControl(stats);
+ setStatsControl(stats);
+
+ // Add 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.9);
+ 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)
+
+ ● Best DX Paths
+
+ `;
+ return div;
+ }
+ });
+ const legend = new LegendControl();
+ map.addControl(legend);
+ setLegendControl(legend);
+ }
+
+ // Add band activity chart
+ if (!chartControl && map && limitedData.length > 0) {
+ const bandCounts = {};
+ limitedData.forEach(spot => {
+ const band = spot.band || 'Unknown';
+ bandCounts[band] = (bandCounts[band] || 0) + 1;
+ });
+
+ const ChartControl = L.Control.extend({
+ options: { position: 'bottomleft' },
+ onAdd: function() {
+ const div = L.DomUtil.create('div', 'wspr-chart');
+ div.style.cssText = `
+ background: rgba(0, 0, 0, 0.9);
+ padding: 10px;
+ border-radius: 5px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 10px;
+ color: white;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
+ max-width: 200px;
+ `;
+
+ let chartHTML = '📊 Band Activity
';
+
+ Object.entries(bandCounts)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 8)
+ .forEach(([band, count]) => {
+ const percentage = (count / limitedData.length) * 100;
+ const barWidth = Math.max(percentage, 5);
+ chartHTML += `
+
+
+ ${band}
+ ${count}
+
+
+
+ `;
+ });
+
+ div.innerHTML = chartHTML;
+ return div;
+ }
+ });
+
+ const chart = new ChartControl();
+ map.addControl(chart);
+ setChartControl(chart);
+ }
+
+ console.log(`[WSPR Plugin] Rendered ${newPaths.length} paths, ${newMarkers.length} markers, ${bestPaths.length} best DX`);
+
+ return () => {
+ newPaths.forEach(layer => {
+ try { map.removeLayer(layer); } catch (e) {}
+ });
+ newMarkers.forEach(layer => {
+ try { map.removeLayer(layer); } catch (e) {}
+ });
+ };
+ }, [enabled, wsprData, map, opacity, snrThreshold, showAnimation, timeWindow, legendControl, statsControl, chartControl]);
+
+ // Cleanup controls on disable
+ useEffect(() => {
+ if (!enabled && map) {
+ [filterControl, legendControl, statsControl, chartControl, heatmapLayer].forEach(control => {
+ if (control) {
+ try {
+ map.removeControl(control);
+ } catch (e) {}
+ }
+ });
+ setFilterControl(null);
+ setLegendControl(null);
+ setStatsControl(null);
+ setChartControl(null);
+ setHeatmapLayer(null);
+ }
+ }, [enabled, map, filterControl, legendControl, statsControl, chartControl, heatmapLayer]);
+
+ // Update opacity
+ 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,
+ filteredCount: wsprData.filter(s => (s.snr || -30) >= snrThreshold).length,
+ filters: { bandFilter, timeWindow, snrThreshold, showAnimation, showHeatmap }
+ };
+}