diff --git a/src/plugins/layers/useWSPR.js b/src/plugins/layers/useWSPR.js index 6960324..5bdae91 100644 --- a/src/plugins/layers/useWSPR.js +++ b/src/plugins/layers/useWSPR.js @@ -1,16 +1,21 @@ import { useState, useEffect, useRef } from 'react'; /** - * WSPR Propagation Heatmap Plugin v1.1.0 + * WSPR Propagation Heatmap Plugin v1.3.0 * - * Visualizes global WSPR (Weak Signal Propagation Reporter) activity as: + * Advanced Features: * - Great circle curved path lines between transmitters and receivers * - Color-coded by signal strength (SNR) - * - Animated signal pulses along paths + * - 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 - * - Optional band filtering - * - Real-time propagation visualization * * Data source: PSK Reporter API (WSPR mode spots) * Update interval: 5 minutes @@ -19,12 +24,12 @@ import { useState, useEffect, useRef } from 'react'; export const metadata = { id: 'wspr', name: 'WSPR Propagation', - description: 'Live WSPR spots showing global HF propagation paths with curved great circle routes', + description: 'Advanced WSPR propagation visualization with filters, analytics, and heatmaps', icon: '📡', category: 'propagation', defaultEnabled: false, defaultOpacity: 0.7, - version: '1.1.0' + version: '1.3.0' }; // Convert grid square to lat/lon @@ -71,25 +76,21 @@ function getLineWeight(snr) { } // Calculate great circle path between two points -// Returns array of lat/lon points forming a smooth curve function getGreatCirclePath(lat1, lon1, lat2, lon2, numPoints = 30) { // Validate input coordinates if (!isFinite(lat1) || !isFinite(lon1) || !isFinite(lat2) || !isFinite(lon2)) { - console.warn('Invalid coordinates for great circle:', { lat1, lon1, lat2, lon2 }); - return [[lat1, lon1], [lat2, lon2]]; // Fallback to straight line + return [[lat1, lon1], [lat2, lon2]]; } - // Check if points are very close (less than 1 degree) + // 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) { - // Points too close, use simple line return [[lat1, lon1], [lat2, lon2]]; } const path = []; - // Convert to radians const toRad = (deg) => (deg * Math.PI) / 180; const toDeg = (rad) => (rad * 180) / Math.PI; @@ -98,22 +99,17 @@ function getGreatCirclePath(lat1, lon1, lat2, lon2, numPoints = 30) { const lat2Rad = toRad(lat2); const lon2Rad = toRad(lon2); - // Calculate great circle distance const cosD = Math.sin(lat1Rad) * Math.sin(lat2Rad) + Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.cos(lon2Rad - lon1Rad); - // Clamp to [-1, 1] to avoid NaN from Math.acos const d = Math.acos(Math.max(-1, Math.min(1, cosD))); - // Check if distance is too small or points are antipodal if (d < 0.01 || Math.abs(d - Math.PI) < 0.01) { - // Use simple line for very small or antipodal distances return [[lat1, lon1], [lat2, lon2]]; } const sinD = Math.sin(d); - // Generate intermediate points along the great circle for (let i = 0; i <= numPoints; i++) { const f = i / numPoints; @@ -127,13 +123,11 @@ function getGreatCirclePath(lat1, lon1, lat2, lon2, numPoints = 30) { const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y))); const lon = toDeg(Math.atan2(y, x)); - // Validate computed point if (isFinite(lat) && isFinite(lon)) { path.push([lat, lon]); } } - // If path generation failed, fall back to straight line if (path.length < 2) { return [[lat1, lon1], [lat2, lon2]]; } @@ -141,25 +135,54 @@ function getGreatCirclePath(lat1, lon1, lat2, lon2, numPoints = 30) { 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([]); - const [bandFilter] = useState('all'); + + // 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 + // 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=30&band=${bandFilter}`); + 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`); + console.log(`[WSPR Plugin] Loaded ${data.spots?.length || 0} spots (${timeWindow}min, band: ${bandFilter})`); } } catch (err) { console.error('WSPR data fetch error:', err); @@ -170,21 +193,125 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { const interval = setInterval(fetchWSPR, 300000); return () => clearInterval(interval); - }, [enabled, bandFilter]); + }, [enabled, bandFilter, timeWindow]); - // Render WSPR paths on map + // 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) {} + try { map.removeLayer(layer); } catch (e) {} }); markerLayers.forEach(layer => { - try { - map.removeLayer(layer); - } catch (e) {} + try { map.removeLayer(layer); } catch (e) {} }); setPathLayers([]); setMarkerLayers([]); @@ -193,44 +320,59 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { const newPaths = []; const newMarkers = []; - const txStations = new Set(); const rxStations = new Set(); - - const limitedData = wsprData.slice(0, 500); + + // 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 spot coordinates + // Validate coordinates if (!spot.senderLat || !spot.senderLon || !spot.receiverLat || !spot.receiverLon) { - console.warn('[WSPR] Skipping spot with invalid coordinates:', spot); return; } - // Ensure coordinates are valid numbers 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)) { - console.warn('[WSPR] Skipping spot with non-finite coordinates:', { sLat, sLon, rLat, rLon }); return; } - // Calculate great circle path for curved line + // Calculate great circle path const pathCoords = getGreatCirclePath(sLat, sLon, rLat, rLon, 30); - // Skip if path is invalid if (!pathCoords || pathCoords.length < 2) { - console.warn('[WSPR] Invalid path coordinates generated'); return; } + // Check if this is a best DX path + const isBestPath = bestPathSet.has(`${spot.sender}-${spot.receiver}`); + const path = L.polyline(pathCoords, { - color: getSNRColor(spot.snr), - weight: getLineWeight(spot.snr), - opacity: opacity * 0.6, - smoothFactor: 1 + 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'; @@ -239,7 +381,7 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { path.bindPopup(`
- 📡 WSPR Spot + ${isBestPath ? '⭐ Best DX Path' : '📡 WSPR Spot'}
@@ -254,10 +396,10 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { 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', @@ -266,7 +408,6 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { fillOpacity: opacity * 0.8, opacity: opacity }); - txMarker.bindTooltip(`TX: ${spot.sender}`, { permanent: false, direction: 'top' }); txMarker.addTo(map); newMarkers.push(txMarker); @@ -275,7 +416,6 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { const rxKey = `${spot.receiver}-${spot.receiverGrid}`; if (!rxStations.has(rxKey)) { rxStations.add(rxKey); - const rxMarker = L.circleMarker([rLat, rLon], { radius: 4, fillColor: '#0088ff', @@ -284,7 +424,6 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { fillOpacity: opacity * 0.8, opacity: opacity }); - rxMarker.bindTooltip(`RX: ${spot.receiver}`, { permanent: false, direction: 'top' }); rxMarker.addTo(map); newMarkers.push(rxMarker); @@ -294,14 +433,60 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { setPathLayers(newPaths); setMarkerLayers(newMarkers); - // Add signal strength legend + // 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.8); + background: rgba(0, 0, 0, 0.9); padding: 10px; border-radius: 5px; font-family: 'JetBrains Mono', monospace; @@ -316,6 +501,9 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) {
Moderate (-10 to 0 dB)
Weak (-20 to -10 dB)
Very Weak (< -20 dB)
+
+ Best DX Paths +
`; return div; } @@ -325,66 +513,91 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { setLegendControl(legend); } - // Add statistics display - if (!statsControl && map) { - const StatsControl = L.Control.extend({ - options: { position: 'topleft' }, + // 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-stats'); + const div = L.DomUtil.create('div', 'wspr-chart'); div.style.cssText = ` - background: rgba(0, 0, 0, 0.8); + background: rgba(0, 0, 0, 0.9); padding: 10px; border-radius: 5px; font-family: 'JetBrains Mono', monospace; - font-size: 11px; + font-size: 10px; color: white; box-shadow: 0 2px 8px rgba(0,0,0,0.3); + max-width: 200px; `; - 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
- `; + + 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 stats = new StatsControl(); - map.addControl(stats); - setStatsControl(stats); + + const chart = new ChartControl(); + map.addControl(chart); + setChartControl(chart); } - console.log(`[WSPR Plugin] Rendered ${newPaths.length} paths, ${newMarkers.length} markers`); + 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) {} + try { map.removeLayer(layer); } catch (e) {} }); newMarkers.forEach(layer => { - try { - map.removeLayer(layer); - } catch (e) {} + try { 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, legendControl, statsControl]); + }, [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) { @@ -405,7 +618,7 @@ export function useLayer({ enabled = false, opacity = 0.7, map = null }) { paths: pathLayers, markers: markerLayers, spotCount: wsprData.length, - legend: legendControl, - stats: statsControl + filteredCount: wsprData.filter(s => (s.snr || -30) >= snrThreshold).length, + filters: { bandFilter, timeWindow, snrThreshold, showAnimation, showHeatmap } }; } diff --git a/src/plugins/layers/wspr/README.md b/src/plugins/layers/wspr/README.md index 1b3598c..6598f21 100644 --- a/src/plugins/layers/wspr/README.md +++ b/src/plugins/layers/wspr/README.md @@ -1,10 +1,10 @@ # WSPR Propagation Heatmap Plugin -**Version:** 1.0.0 +**Version:** 1.3.0 **Category:** Propagation **Icon:** 📡 **Author:** OpenHamClock Contributors -**Last Updated:** 2026-02-03 +**Last Updated:** 2026-02-03 (v1.3.0 Release) --- @@ -14,7 +14,32 @@ The WSPR (Weak Signal Propagation Reporter) Heatmap Plugin provides real-time vi ## Features Implemented -### ✅ Core Features (v1.0.0) +### ✅ v1.3.0 - Advanced Analytics & Filtering (Latest) + +#### **Advanced Filter Controls (v1.2.0)** +- **Band Selector Dropdown**: Filter by specific bands (160m-6m) +- **Time Range Slider**: Choose 15min, 30min, 1hr, 2hr, or 6hr windows +- **SNR Threshold Filter**: Adjustable minimum signal strength (-30 to +10 dB) +- **Animation Toggle**: Enable/disable path animations +- **Heatmap Toggle**: Switch between path view and density heatmap + +#### **Analytics Dashboard (v1.3.0)** +- **Propagation Score**: 0-100 real-time score based on: + - Average SNR (40% weight) + - Path count (30% weight) + - Strong signal ratio (30% weight) +- **Band Activity Chart**: Live bar chart showing spots per band +- **Best DX Paths**: Automatically highlights top 10 longest/strongest paths in cyan +- **Real-Time Statistics**: Dynamic counters for all activity + +#### **Visual Enhancements (v1.3.0)** +- **Animated Paths**: Smooth pulse animation along propagation routes +- **Best Path Highlighting**: Cyan-colored paths for exceptional DX +- **Score Glow Effect**: Pulsing glow on propagation score +- **Interactive Filters**: Hover effects and smooth transitions +- **Band Chart Animation**: Bars grow on load + +### ✅ Core Features (v1.0.0 - v1.1.0) #### **Real-Time Propagation Paths** - Displays signal paths between WSPR transmitters (TX) and receivers (RX) @@ -105,34 +130,36 @@ The WSPR (Weak Signal Propagation Reporter) Heatmap Plugin provides real-time vi --- -## 🚀 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 +## 🚀 Optional Enhancements (Roadmap) + +### ✅ v1.2.0 - Advanced Filtering (COMPLETED) +- [x] **Band Selector UI**: Dropdown menu for band filtering +- [x] **Time Range Slider**: Choose 15min, 30min, 1hr, 2hr, 6hr windows +- [x] **SNR Threshold Filter**: Hide weak signals below threshold +- [ ] **Grid Square Filter**: Show only specific grid squares (future) +- [ ] **Callsign Search**: Highlight paths involving specific callsign (future) + +### ✅ v1.3.0 - Analytics (COMPLETED) +- [x] **Activity Counter**: Show total TX/RX stations count +- [x] **Band Activity Chart**: Bar chart showing spots per band +- [ ] **Hot Spot Heatmap**: Density map of high-activity regions (in progress) +- [x] **Propagation Score**: Overall HF conditions indicator +- [x] **Best DX Paths**: Highlight longest or strongest paths + +### v1.4.0 - Advanced Features (Planned) - [ ] **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 +- [ ] **Full Heatmap Mode**: Density-based heat overlay +- [ ] **Path Recording**: Record and replay propagation patterns + +### v1.1.0 - Enhanced Visualization (COMPLETED) +- [x] **Signal Strength Legend**: Color scale legend in map corner +- [x] **Path Animation**: Animated signal "pulses" from TX to RX +- [ ] **Fading Paths**: Older spots fade out gradually (future) +- [ ] **Station Clustering**: Group nearby stations on zoom-out (future) --- diff --git a/src/styles/main.css b/src/styles/main.css index ef225f9..6fae5d6 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -610,3 +610,127 @@ body::before { .bg-primary { background: var(--bg-primary); } .bg-secondary { background: var(--bg-secondary); } .bg-tertiary { background: var(--bg-tertiary); } + +/* ============================================ + WSPR PLUGIN ANIMATIONS (v1.3.0) + ============================================ */ + +/* Animated path pulse effect */ +@keyframes wspr-pulse { + 0% { + stroke-dashoffset: 1000; + opacity: 0.3; + } + 50% { + opacity: 0.8; + } + 100% { + stroke-dashoffset: 0; + opacity: 0.6; + } +} + +.wspr-animated-path { + stroke-dasharray: 10, 5; + animation: wspr-pulse 3s ease-in-out infinite; +} + +/* Pulsing marker animation */ +@keyframes wspr-marker-pulse { + 0%, 100% { + transform: scale(1); + opacity: 0.8; + } + 50% { + transform: scale(1.3); + opacity: 1; + } +} + +.wspr-marker { + animation: wspr-marker-pulse 2s ease-in-out infinite; +} + +/* Control panel transitions */ +.wspr-filter-control, +.wspr-stats, +.wspr-legend, +.wspr-chart { + transition: all 0.3s ease; +} + +.wspr-filter-control:hover, +.wspr-stats:hover, +.wspr-legend:hover, +.wspr-chart:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.4) !important; +} + +/* Filter input styles */ +.wspr-filter-control select, +.wspr-filter-control input[type="range"] { + transition: all 0.2s ease; +} + +.wspr-filter-control select:hover, +.wspr-filter-control select:focus { + border-color: #00aaff; + outline: none; +} + +.wspr-filter-control input[type="range"]::-webkit-slider-thumb { + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #00aaff; + cursor: pointer; + transition: background 0.2s ease; +} + +.wspr-filter-control input[type="range"]::-webkit-slider-thumb:hover { + background: #00ddff; +} + +.wspr-filter-control input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: #00aaff; + cursor: pointer; + border: none; + transition: background 0.2s ease; +} + +.wspr-filter-control input[type="range"]::-moz-range-thumb:hover { + background: #00ddff; +} + +/* Band activity chart bars animation */ +@keyframes wspr-bar-grow { + from { + width: 0%; + } + to { + width: var(--bar-width); + } +} + +.wspr-chart div[style*="background: linear-gradient"] { + animation: wspr-bar-grow 0.5s ease-out; +} + +/* Propagation score glow effect */ +@keyframes wspr-score-glow { + 0%, 100% { + text-shadow: 0 0 5px currentColor; + } + 50% { + text-shadow: 0 0 15px currentColor, 0 0 25px currentColor; + } +} + +.wspr-stats div[style*="font-size: 18px"] { + animation: wspr-score-glow 2s ease-in-out infinite; +}
TX:${spot.sender} (${spot.senderGrid})