@@ -71,8 +188,8 @@ export const ContestPanel = ({ data, loading }) => {
{/* Contest Calendar Credit */}
diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx
new file mode 100644
index 0000000..651f1e7
--- /dev/null
+++ b/src/components/PSKReporterPanel.jsx
@@ -0,0 +1,261 @@
+/**
+ * PSKReporter Panel
+ * Shows where your digital mode signals are being received
+ * Styled to match DXClusterPanel
+ */
+import React, { useState } from 'react';
+import { usePSKReporter } from '../hooks/usePSKReporter.js';
+import { getBandColor } from '../utils/callsign.js';
+
+const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => {
+ const [timeWindow, setTimeWindow] = useState(15);
+ const [activeTab, setActiveTab] = useState('rx'); // Default to 'rx' (Hearing) - more useful
+
+ const {
+ txReports,
+ txCount,
+ rxReports,
+ rxCount,
+ loading,
+ error,
+ refresh
+ } = usePSKReporter(callsign, {
+ minutes: timeWindow,
+ enabled: callsign && callsign !== 'N0CALL'
+ });
+
+ const reports = activeTab === 'tx' ? txReports : rxReports;
+ const count = activeTab === 'tx' ? txCount : rxCount;
+
+ // Get band color from frequency
+ const getFreqColor = (freqMHz) => {
+ if (!freqMHz) return 'var(--text-muted)';
+ const freq = parseFloat(freqMHz);
+ return getBandColor(freq);
+ };
+
+ // Format age
+ const formatAge = (minutes) => {
+ if (minutes < 1) return 'now';
+ if (minutes < 60) return `${minutes}m`;
+ return `${Math.floor(minutes/60)}h`;
+ };
+
+ if (!callsign || callsign === 'N0CALL') {
+ return (
+
+
+ π‘ PSKReporter
+
+
+ Set callsign in Settings
+
+
+ );
+ }
+
+ return (
+
+ {/* Header - matches DX Cluster style */}
+
+
π‘ PSKReporter
+
+ setTimeWindow(parseInt(e.target.value))}
+ style={{
+ background: 'rgba(100, 100, 100, 0.3)',
+ border: '1px solid #666',
+ color: '#aaa',
+ padding: '2px 4px',
+ borderRadius: '4px',
+ fontSize: '10px',
+ fontFamily: 'JetBrains Mono',
+ cursor: 'pointer'
+ }}
+ >
+ 5m
+ 15m
+ 30m
+ 1h
+
+
+ π
+
+ {onToggleMap && (
+
+ πΊοΈ {showOnMap ? 'ON' : 'OFF'}
+
+ )}
+
+
+
+ {/* Tabs - compact style */}
+
+ setActiveTab('tx')}
+ style={{
+ flex: 1,
+ padding: '4px 6px',
+ background: activeTab === 'tx' ? 'rgba(74, 222, 128, 0.2)' : 'rgba(100, 100, 100, 0.2)',
+ border: `1px solid ${activeTab === 'tx' ? '#4ade80' : '#555'}`,
+ borderRadius: '3px',
+ color: activeTab === 'tx' ? '#4ade80' : '#888',
+ cursor: 'pointer',
+ fontSize: '10px',
+ fontFamily: 'JetBrains Mono'
+ }}
+ >
+ π€ Being Heard ({txCount})
+
+ setActiveTab('rx')}
+ style={{
+ flex: 1,
+ padding: '4px 6px',
+ background: activeTab === 'rx' ? 'rgba(96, 165, 250, 0.2)' : 'rgba(100, 100, 100, 0.2)',
+ border: `1px solid ${activeTab === 'rx' ? '#60a5fa' : '#555'}`,
+ borderRadius: '3px',
+ color: activeTab === 'rx' ? '#60a5fa' : '#888',
+ cursor: 'pointer',
+ fontSize: '10px',
+ fontFamily: 'JetBrains Mono'
+ }}
+ >
+ π₯ Hearing ({rxCount})
+
+
+
+ {/* Reports list - matches DX Cluster style */}
+ {error ? (
+
+ β οΈ Temporarily unavailable
+
+ ) : loading && reports.length === 0 ? (
+
+ ) : reports.length === 0 ? (
+
+ No {activeTab === 'tx' ? 'reception reports' : 'stations heard'}
+
+ ) : (
+
+ {reports.slice(0, 20).map((report, i) => {
+ const freqMHz = report.freqMHz || (report.freq ? (report.freq / 1000000).toFixed(3) : '?');
+ const color = getFreqColor(freqMHz);
+ const displayCall = activeTab === 'tx' ? report.receiver : report.sender;
+ const grid = activeTab === 'tx' ? report.receiverGrid : report.senderGrid;
+
+ return (
+
onShowOnMap && report.lat && report.lon && onShowOnMap(report)}
+ style={{
+ display: 'grid',
+ gridTemplateColumns: '55px 1fr auto',
+ gap: '6px',
+ padding: '4px 6px',
+ borderRadius: '3px',
+ marginBottom: '2px',
+ background: i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent',
+ cursor: report.lat && report.lon ? 'pointer' : 'default',
+ transition: 'background 0.15s',
+ borderLeft: '2px solid transparent'
+ }}
+ onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68, 136, 255, 0.15)'}
+ onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent'}
+ >
+
+ {freqMHz}
+
+
+ {displayCall}
+ {grid && {grid} }
+
+
+ {report.mode}
+ {report.snr !== null && report.snr !== undefined && (
+ = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316',
+ fontWeight: '600'
+ }}>
+ {report.snr > 0 ? '+' : ''}{report.snr}
+
+ )}
+
+ {formatAge(report.age)}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default PSKReporterPanel;
+
+export { PSKReporterPanel };
diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx
index 6ad138d..de8181e 100644
--- a/src/components/WorldMap.jsx
+++ b/src/components/WorldMap.jsx
@@ -21,11 +21,13 @@ export const WorldMap = ({
dxPaths,
dxFilters,
satellites,
+ pskReporterSpots,
showDXPaths,
showDXLabels,
onToggleDXLabels,
showPOTA,
showSatellites,
+ showPSKReporter,
onToggleSatellites,
hoveredSpot
}) => {
@@ -44,6 +46,7 @@ export const WorldMap = ({
const dxPathsMarkersRef = useRef([]);
const satMarkersRef = useRef([]);
const satTracksRef = useRef([]);
+ const pskMarkersRef = useRef([]);
// Load map style from localStorage
const getStoredMapSettings = () => {
@@ -416,6 +419,71 @@ export const WorldMap = ({
}
}, [satellites, showSatellites]);
+ // Update PSKReporter markers
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ const map = mapInstanceRef.current;
+
+ pskMarkersRef.current.forEach(m => map.removeLayer(m));
+ pskMarkersRef.current = [];
+
+ // Validate deLocation exists and has valid coordinates
+ const hasValidDE = deLocation &&
+ typeof deLocation.lat === 'number' && !isNaN(deLocation.lat) &&
+ typeof deLocation.lon === 'number' && !isNaN(deLocation.lon);
+
+ if (showPSKReporter && pskReporterSpots && pskReporterSpots.length > 0 && hasValidDE) {
+ pskReporterSpots.forEach(spot => {
+ // Validate spot coordinates are valid numbers
+ const spotLat = parseFloat(spot.lat);
+ const spotLon = parseFloat(spot.lon);
+
+ if (!isNaN(spotLat) && !isNaN(spotLon)) {
+ const displayCall = spot.receiver || spot.sender;
+ const freqMHz = spot.freqMHz || (spot.freq ? (spot.freq / 1000000).toFixed(3) : '?');
+ const bandColor = getBandColor(parseFloat(freqMHz));
+
+ try {
+ // Draw line from DE to spot location
+ const points = getGreatCirclePoints(
+ deLocation.lat, deLocation.lon,
+ spotLat, spotLon,
+ 50
+ );
+
+ // Validate points before creating polyline
+ if (points && points.length > 1 && points.every(p => Array.isArray(p) && !isNaN(p[0]) && !isNaN(p[1]))) {
+ const line = L.polyline(points, {
+ color: bandColor,
+ weight: 1.5,
+ opacity: 0.5,
+ dashArray: '4, 4'
+ }).addTo(map);
+ pskMarkersRef.current.push(line);
+ }
+
+ // Add small dot marker at spot location
+ const circle = L.circleMarker([spotLat, spotLon], {
+ radius: 4,
+ fillColor: bandColor,
+ color: '#fff',
+ weight: 1,
+ opacity: 0.9,
+ fillOpacity: 0.8
+ }).bindPopup(`
+
${displayCall}
+ ${spot.mode} @ ${freqMHz} MHz
+ ${spot.snr !== null ? `SNR: ${spot.snr > 0 ? '+' : ''}${spot.snr} dB` : ''}
+ `).addTo(map);
+ pskMarkersRef.current.push(circle);
+ } catch (err) {
+ console.warn('Error rendering PSKReporter spot:', err);
+ }
+ }
+ });
+ }
+ }, [pskReporterSpots, showPSKReporter, deLocation]);
+
return (
diff --git a/src/components/index.js b/src/components/index.js
index 27279cc..7d84316 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -16,3 +16,4 @@ export { DXFilterManager } from './DXFilterManager.jsx';
export { SolarPanel } from './SolarPanel.jsx';
export { PropagationPanel } from './PropagationPanel.jsx';
export { DXpeditionPanel } from './DXpeditionPanel.jsx';
+export { PSKReporterPanel } from './PSKReporterPanel.jsx';
diff --git a/src/hooks/index.js b/src/hooks/index.js
index 2f981dc..b07aec3 100644
--- a/src/hooks/index.js
+++ b/src/hooks/index.js
@@ -15,3 +15,4 @@ export { useMySpots } from './useMySpots.js';
export { useDXpeditions } from './useDXpeditions.js';
export { useSatellites } from './useSatellites.js';
export { useSolarIndices } from './useSolarIndices.js';
+export { usePSKReporter } from './usePSKReporter.js';
diff --git a/src/hooks/usePSKReporter.js b/src/hooks/usePSKReporter.js
new file mode 100644
index 0000000..225201d
--- /dev/null
+++ b/src/hooks/usePSKReporter.js
@@ -0,0 +1,147 @@
+/**
+ * usePSKReporter Hook
+ * Fetches PSKReporter data showing where your digital mode signals are being received
+ *
+ * Uses HTTP API with server-side caching to respect PSKReporter rate limits.
+ *
+ * For real-time MQTT updates, see mqtt.pskreporter.info (requires mqtt.js library)
+ */
+import { useState, useEffect, useCallback } from 'react';
+
+// Convert grid square to lat/lon
+function gridToLatLon(grid) {
+ if (!grid || grid.length < 4) return null;
+
+ const g = grid.toUpperCase();
+ const lon = (g.charCodeAt(0) - 65) * 20 - 180;
+ const lat = (g.charCodeAt(1) - 65) * 10 - 90;
+ const lonMin = parseInt(g[2]) * 2;
+ const latMin = parseInt(g[3]) * 1;
+
+ let finalLon = lon + lonMin + 1;
+ let finalLat = lat + latMin + 0.5;
+
+ if (grid.length >= 6) {
+ const lonSec = (g.charCodeAt(4) - 65) * (2/24);
+ const latSec = (g.charCodeAt(5) - 65) * (1/24);
+ finalLon = lon + lonMin + lonSec + (1/24);
+ finalLat = lat + latMin + latSec + (0.5/24);
+ }
+
+ return { lat: finalLat, lon: finalLon };
+}
+
+export const usePSKReporter = (callsign, options = {}) => {
+ const {
+ minutes = 15, // Time window in minutes (default 15)
+ enabled = true, // Enable/disable fetching
+ refreshInterval = 300000, // Refresh every 5 minutes (PSKReporter friendly)
+ maxSpots = 100 // Max spots to display
+ } = options;
+
+ const [txReports, setTxReports] = useState([]);
+ const [rxReports, setRxReports] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [lastUpdate, setLastUpdate] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ if (!callsign || callsign === 'N0CALL' || !enabled) {
+ setTxReports([]);
+ setRxReports([]);
+ setLoading(false);
+ return;
+ }
+
+ try {
+ setError(null);
+
+ // Fetch combined endpoint from our server (handles caching)
+ const response = await fetch(`/api/pskreporter/${encodeURIComponent(callsign)}?minutes=${minutes}`);
+
+ if (response.ok) {
+ const data = await response.json();
+
+ // Process TX reports (where I'm being heard)
+ const txData = data.tx?.reports || [];
+ const processedTx = txData
+ .map(r => ({
+ ...r,
+ // Ensure we have location data
+ lat: r.lat || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lat : null),
+ lon: r.lon || (r.receiverGrid ? gridToLatLon(r.receiverGrid)?.lon : null),
+ age: r.age || Math.floor((Date.now() - r.timestamp) / 60000)
+ }))
+ .filter(r => r.lat && r.lon)
+ .slice(0, maxSpots);
+
+ // Process RX reports (what I'm hearing)
+ const rxData = data.rx?.reports || [];
+ const processedRx = rxData
+ .map(r => ({
+ ...r,
+ lat: r.lat || (r.senderGrid ? gridToLatLon(r.senderGrid)?.lat : null),
+ lon: r.lon || (r.senderGrid ? gridToLatLon(r.senderGrid)?.lon : null),
+ age: r.age || Math.floor((Date.now() - r.timestamp) / 60000)
+ }))
+ .filter(r => r.lat && r.lon)
+ .slice(0, maxSpots);
+
+ setTxReports(processedTx);
+ setRxReports(processedRx);
+ setLastUpdate(new Date());
+
+ // Check for errors in response
+ if (data.error || data.tx?.error || data.rx?.error) {
+ setError(data.error || data.tx?.error || data.rx?.error);
+ }
+ } else {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ } catch (err) {
+ console.error('PSKReporter fetch error:', err);
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }, [callsign, minutes, enabled, maxSpots]);
+
+ useEffect(() => {
+ fetchData();
+
+ if (enabled && refreshInterval > 0) {
+ const interval = setInterval(fetchData, refreshInterval);
+ return () => clearInterval(interval);
+ }
+ }, [fetchData, enabled, refreshInterval]);
+
+ // Computed stats
+ const txBands = [...new Set(txReports.map(r => r.band))].filter(b => b && b !== 'Unknown');
+ const txModes = [...new Set(txReports.map(r => r.mode))].filter(Boolean);
+
+ const stats = {
+ txCount: txReports.length,
+ rxCount: rxReports.length,
+ txBands,
+ txModes,
+ bestSnr: txReports.length > 0
+ ? txReports.reduce((max, r) => (r.snr || -99) > (max?.snr || -99) ? r : max, null)
+ : null
+ };
+
+ return {
+ txReports,
+ txCount: txReports.length,
+ rxReports,
+ rxCount: rxReports.length,
+ stats,
+ loading,
+ error,
+ connected: false, // HTTP mode - not real-time connected
+ source: 'http',
+ lastUpdate,
+ refresh: fetchData
+ };
+};
+
+export default usePSKReporter;
diff --git a/src/utils/config.js b/src/utils/config.js
index 42f0024..464617a 100644
--- a/src/utils/config.js
+++ b/src/utils/config.js
@@ -187,6 +187,16 @@ export const MAP_STYLES = {
name: 'Gray',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}',
attribution: '© Esri'
+ },
+ political: {
+ name: 'Political',
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',
+ attribution: '© Esri'
+ },
+ natgeo: {
+ name: 'Nat Geo',
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}',
+ attribution: '© Esri, National Geographic'
}
};