import { useState, useEffect, useRef } from 'react'; /** * WSPR Propagation Heatmap Plugin v1.4.2 * * 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.4.0) * - Band activity chart (v1.3.0) * - Propagation score indicator (v1.3.0) * - Best DX paths highlighting (v1.3.0) * - Draggable control panels with CTRL+drag (v1.4.0) * - Persistent panel positions (v1.4.1) * - Proper cleanup on disable (v1.4.1) * - Fixed duplicate control creation (v1.4.2) * - Performance optimizations (v1.4.2) * - 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.4.2' }; // 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); } // Make control panel draggable with CTRL+drag 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 hint element.title = 'Hold CTRL and drag to reposition'; let isDragging = false; let startX, startY, startLeft, startTop; // Update cursor based on CTRL key const updateCursor = (e) => { if (e.ctrlKey) { element.style.cursor = 'grab'; } else { element.style.cursor = 'default'; } }; element.addEventListener('mouseenter', updateCursor); element.addEventListener('mousemove', updateCursor); document.addEventListener('keydown', (e) => { if (e.key === 'Control') updateCursor(e); }); document.addEventListener('keyup', (e) => { if (e.key === 'Control') updateCursor(e); }); element.addEventListener('mousedown', function(e) { // Only allow dragging with CTRL key if (!e.ctrlKey) return; // 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.cursor = 'grabbing'; 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'; updateCursor(e); // 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([]); 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 (refs to avoid recreation) const legendControlRef = useRef(null); const statsControlRef = useRef(null); const filterControlRef = useRef(null); const chartControlRef = useRef(null); 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 UI controls once (v1.2.0+) useEffect(() => { if (!enabled || !map) return; if (filterControlRef.current || statsControlRef.current || legendControlRef.current || chartControlRef.current) 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); filterControlRef.current = 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'); 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) => { console.log('[WSPR] Heatmap toggle:', e.target.checked); setShowHeatmap(e.target.checked); }); }, 100); // Create stats control 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); `; div.innerHTML = `
📊 WSPR Activity
Propagation Score
--/100
Paths: 0
TX Stations: 0
RX Stations: 0
Total: 0
Initializing...
`; return div; } }); const stats = new StatsControl(); map.addControl(stats); statsControlRef.current = stats; setStatsControl(stats); setTimeout(() => { const container = document.querySelector('.wspr-stats'); if (container) makeDraggable(container, 'wspr-stats-position'); }, 150); // Create legend control 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); legendControlRef.current = legend; setLegendControl(legend); setTimeout(() => { const container = document.querySelector('.wspr-legend'); if (container) makeDraggable(container, 'wspr-legend-position'); }, 150); // Create band chart control 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; `; div.innerHTML = '
📊 Band Activity
Loading...
'; return div; } }); const chart = new ChartControl(); map.addControl(chart); chartControlRef.current = chart; setChartControl(chart); setTimeout(() => { const container = document.querySelector('.wspr-chart'); if (container) makeDraggable(container, 'wspr-chart-position'); }, 150); console.log('[WSPR] All controls created once'); }, [enabled, map]); // 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 stats content only (don't recreate control) const propScore = calculatePropagationScore(limitedData); const scoreColor = propScore > 70 ? '#00ff00' : propScore > 40 ? '#ffaa00' : '#ff6600'; const totalStations = txStations.size + rxStations.size; // Update existing stats panel content if it exists setTimeout(() => { const statsContainer = document.querySelector('.wspr-stats'); if (statsContainer && enabled) { statsContainer.innerHTML = `
📊 WSPR Activity
Propagation Score
${propScore}/100
Paths: ${newPaths.length}
TX Stations: ${txStations.size}
RX Stations: ${rxStations.size}
Total: ${totalStations}
Last ${timeWindow} min
`; } }, 50); // Update band chart content if it exists setTimeout(() => { const chartContainer = document.querySelector('.wspr-chart'); if (chartContainer && limitedData.length > 0 && enabled) { const bandCounts = {}; limitedData.forEach(spot => { const band = spot.band || 'Unknown'; bandCounts[band] = (bandCounts[band] || 0) + 1; }); 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}
`; }); chartContainer.innerHTML = chartHTML; } }, 50); 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, snrThreshold, showAnimation, timeWindow]); // 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 - FIX: properly remove all controls and layers useEffect(() => { if (!enabled && map) { console.log('[WSPR] Plugin disabled - cleaning up all controls and layers'); // Remove filter control if (filterControlRef.current) { try { map.removeControl(filterControlRef.current); console.log('[WSPR] Removed filter control'); } catch (e) { console.error('[WSPR] Error removing filter control:', e); } filterControlRef.current = null; setFilterControl(null); } // Remove legend control if (legendControlRef.current) { try { map.removeControl(legendControlRef.current); console.log('[WSPR] Removed legend control'); } catch (e) { console.error('[WSPR] Error removing legend control:', e); } legendControlRef.current = null; setLegendControl(null); } // Remove stats control if (statsControlRef.current) { try { map.removeControl(statsControlRef.current); console.log('[WSPR] Removed stats control'); } catch (e) { console.error('[WSPR] Error removing stats control:', e); } statsControlRef.current = null; setStatsControl(null); } // Remove chart control if (chartControlRef.current) { try { map.removeControl(chartControlRef.current); console.log('[WSPR] Removed chart control'); } catch (e) { console.error('[WSPR] Error removing chart control:', e); } chartControlRef.current = null; setChartControl(null); } // Remove heatmap layer if (heatmapLayer) { try { map.removeLayer(heatmapLayer); console.log('[WSPR] Removed heatmap layer'); } catch (e) { console.error('[WSPR] Error removing heatmap layer:', e); } setHeatmapLayer(null); } // Clear all paths and markers pathLayers.forEach(layer => { try { map.removeLayer(layer); } catch (e) {} }); markerLayers.forEach(layer => { try { map.removeLayer(layer); } catch (e) {} }); setPathLayers([]); setMarkerLayers([]); } }, [enabled, map, heatmapLayer, pathLayers, markerLayers]); // 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 } }; }