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'}
| TX: | ${spot.sender} (${spot.senderGrid}) |
@@ -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;
+}