@@ -71,8 +188,8 @@ export const ContestPanel = ({ data, loading }) => {
{/* Contest Calendar Credit */}
diff --git a/src/components/PSKFilterManager.jsx b/src/components/PSKFilterManager.jsx
new file mode 100644
index 0000000..ab5172a
--- /dev/null
+++ b/src/components/PSKFilterManager.jsx
@@ -0,0 +1,405 @@
+/**
+ * PSKFilterManager Component
+ * Filter modal for PSKReporter spots - Bands, Grids, Modes
+ */
+import React, { useState } from 'react';
+
+const BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm'];
+const MODES = ['FT8', 'FT4', 'JS8', 'WSPR', 'JT65', 'JT9', 'MSK144', 'Q65', 'FST4', 'FST4W'];
+
+// Common grid field prefixes by region
+const GRID_REGIONS = [
+ { name: 'North America East', grids: ['FN', 'FM', 'EN', 'EM', 'DN', 'DM'] },
+ { name: 'North America West', grids: ['CN', 'CM', 'DM', 'DN', 'BN', 'BM'] },
+ { name: 'Europe', grids: ['JO', 'JN', 'IO', 'IN', 'KO', 'KN', 'LO', 'LN'] },
+ { name: 'South America', grids: ['GG', 'GH', 'GI', 'FG', 'FH', 'FI', 'FF', 'FE'] },
+ { name: 'Asia', grids: ['PM', 'PL', 'OM', 'OL', 'QL', 'QM', 'NM', 'NL'] },
+ { name: 'Oceania', grids: ['QF', 'QG', 'PF', 'PG', 'RF', 'RG', 'OF', 'OG'] },
+ { name: 'Africa', grids: ['KH', 'KG', 'JH', 'JG', 'IH', 'IG'] },
+];
+
+export const PSKFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => {
+ const [activeTab, setActiveTab] = useState('bands');
+ const [customGrid, setCustomGrid] = useState('');
+
+ if (!isOpen) return null;
+
+ const toggleArrayItem = (key, item) => {
+ const current = filters[key] || [];
+ const newArray = current.includes(item)
+ ? current.filter(x => x !== item)
+ : [...current, item];
+ onFilterChange({ ...filters, [key]: newArray.length ? newArray : undefined });
+ };
+
+ const selectAll = (key, items) => {
+ onFilterChange({ ...filters, [key]: [...items] });
+ };
+
+ const clearFilter = (key) => {
+ const newFilters = { ...filters };
+ delete newFilters[key];
+ onFilterChange(newFilters);
+ };
+
+ const clearAllFilters = () => {
+ onFilterChange({});
+ };
+
+ const addCustomGrid = () => {
+ if (customGrid.trim() && customGrid.length >= 2) {
+ const grid = customGrid.toUpperCase().substring(0, 2);
+ const current = filters?.grids || [];
+ if (!current.includes(grid)) {
+ onFilterChange({ ...filters, grids: [...current, grid] });
+ }
+ setCustomGrid('');
+ }
+ };
+
+ const getActiveFilterCount = () => {
+ let count = 0;
+ if (filters?.bands?.length) count += filters.bands.length;
+ if (filters?.grids?.length) count += filters.grids.length;
+ if (filters?.modes?.length) count += filters.modes.length;
+ return count;
+ };
+
+ const tabStyle = (active) => ({
+ padding: '8px 16px',
+ background: active ? 'var(--bg-tertiary)' : 'transparent',
+ border: 'none',
+ borderBottom: active ? '2px solid var(--accent-cyan)' : '2px solid transparent',
+ color: active ? 'var(--accent-cyan)' : 'var(--text-muted)',
+ fontSize: '13px',
+ cursor: 'pointer',
+ fontFamily: 'inherit'
+ });
+
+ const chipStyle = (selected) => ({
+ padding: '6px 12px',
+ background: selected ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)',
+ border: `1px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
+ borderRadius: '4px',
+ color: selected ? 'var(--accent-cyan)' : 'var(--text-secondary)',
+ fontSize: '12px',
+ cursor: 'pointer',
+ fontFamily: 'JetBrains Mono, monospace'
+ });
+
+ const renderBandsTab = () => (
+
+
+
+ Filter by Band
+
+
+ selectAll('bands', BANDS)}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Select All
+
+ clearFilter('bands')}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Clear
+
+
+
+
+ {BANDS.map(band => (
+ toggleArrayItem('bands', band)}
+ style={chipStyle(filters?.bands?.includes(band))}
+ >
+ {band}
+
+ ))}
+
+
+ {filters?.bands?.length
+ ? `Showing only: ${filters.bands.join(', ')}`
+ : 'Showing all bands (no filter)'}
+
+
+ );
+
+ const renderGridsTab = () => (
+
+
+
+ Filter by Grid Square
+
+ clearFilter('grids')}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Clear All
+
+
+
+ {/* Custom grid input */}
+
+ setCustomGrid(e.target.value.toUpperCase())}
+ maxLength={2}
+ onKeyPress={(e) => e.key === 'Enter' && addCustomGrid()}
+ style={{
+ flex: 1,
+ padding: '8px 12px',
+ background: 'var(--bg-tertiary)',
+ border: '1px solid var(--border-color)',
+ borderRadius: '4px',
+ color: 'var(--text-primary)',
+ fontSize: '13px',
+ fontFamily: 'JetBrains Mono'
+ }}
+ />
+
+ Add
+
+
+
+ {/* Selected grids */}
+ {filters?.grids?.length > 0 && (
+
+
+ Active Grid Filters:
+
+
+ {filters.grids.map(grid => (
+ toggleArrayItem('grids', grid)}
+ style={{
+ ...chipStyle(true),
+ display: 'flex',
+ alignItems: 'center',
+ gap: '6px'
+ }}
+ >
+ {grid}
+ Γ
+
+ ))}
+
+
+ )}
+
+ {/* Quick select by region */}
+
+ Quick Select by Region:
+
+ {GRID_REGIONS.map(region => (
+
+
+ {region.name}
+
+
+ {region.grids.map(grid => (
+ toggleArrayItem('grids', grid)}
+ style={{
+ ...chipStyle(filters?.grids?.includes(grid)),
+ padding: '4px 8px',
+ fontSize: '11px'
+ }}
+ >
+ {grid}
+
+ ))}
+
+
+ ))}
+
+ );
+
+ const renderModesTab = () => (
+
+
+
+ Filter by Mode
+
+
+ selectAll('modes', MODES)}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Select All
+
+ clearFilter('modes')}
+ style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}
+ >
+ Clear
+
+
+
+
+ {MODES.map(mode => (
+ toggleArrayItem('modes', mode)}
+ style={chipStyle(filters?.modes?.includes(mode))}
+ >
+ {mode}
+
+ ))}
+
+
+ {filters?.modes?.length
+ ? `Showing only: ${filters.modes.join(', ')}`
+ : 'Showing all modes (no filter)'}
+
+
+ );
+
+ return (
+
e.target === e.currentTarget && onClose()}
+ >
+
+ {/* Header */}
+
+
+
+ π‘ PSKReporter Filters
+
+
+ {getActiveFilterCount()} filter{getActiveFilterCount() !== 1 ? 's' : ''} active
+
+
+
+ Γ
+
+
+
+ {/* Tabs */}
+
+ setActiveTab('bands')} style={tabStyle(activeTab === 'bands')}>
+ Bands {filters?.bands?.length ? `(${filters.bands.length})` : ''}
+
+ setActiveTab('grids')} style={tabStyle(activeTab === 'grids')}>
+ Grids {filters?.grids?.length ? `(${filters.grids.length})` : ''}
+
+ setActiveTab('modes')} style={tabStyle(activeTab === 'modes')}>
+ Modes {filters?.modes?.length ? `(${filters.modes.length})` : ''}
+
+
+
+ {/* Tab Content */}
+
+ {activeTab === 'bands' && renderBandsTab()}
+ {activeTab === 'grids' && renderGridsTab()}
+ {activeTab === 'modes' && renderModesTab()}
+
+
+ {/* Footer */}
+
+
+ Clear All Filters
+
+
+ Done
+
+
+
+
+ );
+};
+
+export default PSKFilterManager;
diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx
new file mode 100644
index 0000000..d7fff17
--- /dev/null
+++ b/src/components/PSKReporterPanel.jsx
@@ -0,0 +1,324 @@
+/**
+ * PSKReporter Panel
+ * Shows where your digital mode signals are being received
+ * Uses MQTT WebSocket for real-time data
+ */
+import React, { useState, useMemo } from 'react';
+import { usePSKReporter } from '../hooks/usePSKReporter.js';
+import { getBandColor } from '../utils/callsign.js';
+
+const PSKReporterPanel = ({
+ callsign,
+ onShowOnMap,
+ showOnMap,
+ onToggleMap,
+ filters = {},
+ onOpenFilters
+}) => {
+ const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard)
+
+ const {
+ txReports,
+ txCount,
+ rxReports,
+ rxCount,
+ loading,
+ error,
+ connected,
+ source,
+ refresh
+ } = usePSKReporter(callsign, {
+ minutes: 15,
+ enabled: callsign && callsign !== 'N0CALL'
+ });
+
+ // Filter reports by band, grid, and mode
+ const filterReports = (reports) => {
+ return reports.filter(r => {
+ // Band filter
+ if (filters?.bands?.length && !filters.bands.includes(r.band)) return false;
+
+ // Grid filter (prefix match)
+ if (filters?.grids?.length) {
+ const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid;
+ if (!grid) return false;
+ const gridPrefix = grid.substring(0, 2).toUpperCase();
+ if (!filters.grids.includes(gridPrefix)) return false;
+ }
+
+ // Mode filter
+ if (filters?.modes?.length && !filters.modes.includes(r.mode)) return false;
+
+ return true;
+ });
+ };
+
+ const filteredTx = useMemo(() => filterReports(txReports), [txReports, filters, activeTab]);
+ const filteredRx = useMemo(() => filterReports(rxReports), [rxReports, filters, activeTab]);
+ const filteredReports = activeTab === 'tx' ? filteredTx : filteredRx;
+
+ // Count active filters
+ const getActiveFilterCount = () => {
+ let count = 0;
+ if (filters?.bands?.length) count++;
+ if (filters?.grids?.length) count++;
+ if (filters?.modes?.length) count++;
+ return count;
+ };
+ const filterCount = getActiveFilterCount();
+
+ // 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`;
+ };
+
+ // Get status indicator
+ const getStatusIndicator = () => {
+ if (connected) {
+ return
β LIVE ;
+ }
+ if (source === 'connecting' || source === 'reconnecting') {
+ return
β {source} ;
+ }
+ if (error) {
+ return
β offline ;
+ }
+ return null;
+ };
+
+ if (!callsign || callsign === 'N0CALL') {
+ return (
+
+
+ π‘ PSKReporter
+
+
+ Set callsign in Settings
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
π‘ PSKReporter {getStatusIndicator()}
+
+
+ {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount}
+
+ 0 ? 'rgba(255, 170, 0, 0.3)' : 'rgba(100, 100, 100, 0.3)',
+ border: `1px solid ${filterCount > 0 ? '#ffaa00' : '#666'}`,
+ color: filterCount > 0 ? '#ffaa00' : '#888',
+ padding: '2px 8px',
+ borderRadius: '4px',
+ fontSize: '10px',
+ fontFamily: 'JetBrains Mono',
+ cursor: 'pointer'
+ }}
+ >
+ π Filters
+
+
+ π
+
+ {onToggleMap && (
+
+ πΊοΈ {showOnMap ? 'ON' : 'OFF'}
+
+ )}
+
+
+
+ {/* Tabs */}
+
+ 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 ({filterCount > 0 ? `${filteredTx.length}` : 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 ({filterCount > 0 ? `${filteredRx.length}` : rxCount})
+
+
+
+ {/* Reports list */}
+ {error && !connected ? (
+
+ β οΈ Connection failed - click π to retry
+
+ ) : loading && filteredReports.length === 0 && filterCount === 0 ? (
+
+
+ Connecting to MQTT...
+
+ ) : !connected && filteredReports.length === 0 && filterCount === 0 ? (
+
+ Waiting for connection...
+
+ ) : filteredReports.length === 0 ? (
+
+ {filterCount > 0
+ ? 'No spots match filters'
+ : activeTab === 'tx'
+ ? 'Waiting for spots... (TX to see reports)'
+ : 'No stations heard yet'}
+
+ ) : (
+
+ {filteredReports.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 eaf1d45..b7043e6 100644
--- a/src/components/WorldMap.jsx
+++ b/src/components/WorldMap.jsx
@@ -1,6 +1,6 @@
/**
* WorldMap Component
- * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites
+ * Leaflet map with DE/DX markers, terminator, DX paths, POTA, satellites, PSKReporter
*/
import React, { useRef, useEffect, useState } from 'react';
import { MAP_STYLES } from '../utils/config.js';
@@ -25,11 +25,13 @@ export const WorldMap = ({
dxPaths,
dxFilters,
satellites,
+ pskReporterSpots,
showDXPaths,
showDXLabels,
onToggleDXLabels,
showPOTA,
showSatellites,
+ showPSKReporter,
onToggleSatellites,
hoveredSpot
}) => {
@@ -48,10 +50,11 @@ export const WorldMap = ({
const dxPathsMarkersRef = useRef([]);
const satMarkersRef = useRef([]);
const satTracksRef = useRef([]);
+ const pskMarkersRef = useRef([]);
+ // Plugin system refs and state
const pluginLayersRef = useRef({});
const [pluginLayerStates, setPluginLayerStates] = useState({});
-
// Load map style from localStorage
const getStoredMapSettings = () => {
@@ -424,7 +427,6 @@ export const WorldMap = ({
}
}, [satellites, showSatellites]);
-
// Plugin layer system - properly load saved states
useEffect(() => {
if (!mapInstanceRef.current) return;
@@ -503,16 +505,76 @@ export const WorldMap = ({
}
}, [pluginLayerStates]);
+ // Update PSKReporter markers
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ const map = mapInstanceRef.current;
+
+ pskMarkersRef.current.forEach(m => map.removeLayer(m));
+ pskMarkersRef.current = [];
-// return (
-//
-//
+ // 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 (
- {/* Render all plugin layers - GENERIC */}
+ {/* Render all plugin layers */}
{mapInstanceRef.current && getAllLayers().map(layerDef => (
))}
-
{/* Map style dropdown */}
= 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 };
+}
+
+// Get band name from frequency in Hz
+function getBandFromHz(freqHz) {
+ const freqMHz = freqHz / 1000000;
+ if (freqMHz >= 1.8 && freqMHz <= 2) return '160m';
+ if (freqMHz >= 3.5 && freqMHz <= 4) return '80m';
+ if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60m';
+ if (freqMHz >= 7 && freqMHz <= 7.3) return '40m';
+ if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30m';
+ if (freqMHz >= 14 && freqMHz <= 14.35) return '20m';
+ if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17m';
+ if (freqMHz >= 21 && freqMHz <= 21.45) return '15m';
+ if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12m';
+ if (freqMHz >= 28 && freqMHz <= 29.7) return '10m';
+ if (freqMHz >= 50 && freqMHz <= 54) return '6m';
+ if (freqMHz >= 144 && freqMHz <= 148) return '2m';
+ if (freqMHz >= 420 && freqMHz <= 450) return '70cm';
+ return 'Unknown';
+}
+
+export const usePSKReporter = (callsign, options = {}) => {
+ const {
+ minutes = 15, // Time window to keep spots
+ enabled = true, // Enable/disable fetching
+ maxSpots = 100 // Max spots to keep
+ } = options;
+
+ const [txReports, setTxReports] = useState([]);
+ const [rxReports, setRxReports] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [connected, setConnected] = useState(false);
+ const [lastUpdate, setLastUpdate] = useState(null);
+ const [source, setSource] = useState('connecting');
+
+ const clientRef = useRef(null);
+ const txReportsRef = useRef([]);
+ const rxReportsRef = useRef([]);
+ const mountedRef = useRef(true);
+
+ // Clean old spots (older than specified minutes)
+ const cleanOldSpots = useCallback((spots, maxAgeMinutes) => {
+ const cutoff = Date.now() - (maxAgeMinutes * 60 * 1000);
+ return spots.filter(s => s.timestamp > cutoff).slice(0, maxSpots);
+ }, [maxSpots]);
+
+ // Process incoming MQTT message
+ const processMessage = useCallback((topic, message) => {
+ if (!mountedRef.current) return;
+
+ try {
+ const data = JSON.parse(message.toString());
+
+ // PSKReporter MQTT message format
+ // sa=sender callsign, sl=sender locator, ra=receiver callsign, rl=receiver locator
+ // f=frequency, md=mode, rp=snr (report), t=timestamp
+ const {
+ sa: senderCallsign,
+ sl: senderLocator,
+ ra: receiverCallsign,
+ rl: receiverLocator,
+ f: frequency,
+ md: mode,
+ rp: snr,
+ t: timestamp
+ } = data;
+
+ if (!senderCallsign || !receiverCallsign) return;
+
+ const senderLoc = gridToLatLon(senderLocator);
+ const receiverLoc = gridToLatLon(receiverLocator);
+ const freq = parseInt(frequency) || 0;
+ const now = Date.now();
+
+ const report = {
+ sender: senderCallsign,
+ senderGrid: senderLocator,
+ receiver: receiverCallsign,
+ receiverGrid: receiverLocator,
+ freq,
+ freqMHz: freq ? (freq / 1000000).toFixed(3) : '?',
+ band: getBandFromHz(freq),
+ mode: mode || 'Unknown',
+ snr: snr !== undefined ? parseInt(snr) : null,
+ timestamp: timestamp ? timestamp * 1000 : now,
+ age: 0,
+ lat: null,
+ lon: null
+ };
+
+ const upperCallsign = callsign?.toUpperCase();
+ if (!upperCallsign) return;
+
+ // If I'm the sender, this is a TX report (someone heard me)
+ if (senderCallsign.toUpperCase() === upperCallsign) {
+ report.lat = receiverLoc?.lat;
+ report.lon = receiverLoc?.lon;
+
+ // Add to front, dedupe by receiver+freq, limit size
+ txReportsRef.current = [report, ...txReportsRef.current]
+ .filter((r, i, arr) =>
+ i === arr.findIndex(x => x.receiver === r.receiver && Math.abs(x.freq - r.freq) < 1000)
+ )
+ .slice(0, maxSpots);
+
+ setTxReports(cleanOldSpots([...txReportsRef.current], minutes));
+ setLastUpdate(new Date());
+ }
+
+ // If I'm the receiver, this is an RX report (I heard someone)
+ if (receiverCallsign.toUpperCase() === upperCallsign) {
+ report.lat = senderLoc?.lat;
+ report.lon = senderLoc?.lon;
+
+ rxReportsRef.current = [report, ...rxReportsRef.current]
+ .filter((r, i, arr) =>
+ i === arr.findIndex(x => x.sender === r.sender && Math.abs(x.freq - r.freq) < 1000)
+ )
+ .slice(0, maxSpots);
+
+ setRxReports(cleanOldSpots([...rxReportsRef.current], minutes));
+ setLastUpdate(new Date());
+ }
+
+ } catch (err) {
+ // Silently ignore parse errors - malformed messages happen
+ }
+ }, [callsign, minutes, maxSpots, cleanOldSpots]);
+
+ // Connect to MQTT
+ useEffect(() => {
+ mountedRef.current = true;
+
+ if (!callsign || callsign === 'N0CALL' || !enabled) {
+ setTxReports([]);
+ setRxReports([]);
+ setLoading(false);
+ setSource('disabled');
+ setConnected(false);
+ return;
+ }
+
+ const upperCallsign = callsign.toUpperCase();
+
+ // Clear old data
+ txReportsRef.current = [];
+ rxReportsRef.current = [];
+ setTxReports([]);
+ setRxReports([]);
+ setLoading(true);
+ setError(null);
+ setSource('connecting');
+
+ console.log(`[PSKReporter MQTT] Connecting for ${upperCallsign}...`);
+
+ // Connect to PSKReporter MQTT via WebSocket
+ const client = mqtt.connect('wss://mqtt.pskreporter.info:1886/mqtt', {
+ clientId: `ohc_${upperCallsign}_${Math.random().toString(16).substr(2, 6)}`,
+ clean: true,
+ connectTimeout: 15000,
+ reconnectPeriod: 60000,
+ keepalive: 60
+ });
+
+ clientRef.current = client;
+
+ client.on('connect', () => {
+ if (!mountedRef.current) return;
+
+ console.log('[PSKReporter MQTT] Connected!');
+ setConnected(true);
+ setLoading(false);
+ setSource('mqtt');
+ setError(null);
+
+ // Subscribe to spots where we are the sender (being heard by others)
+ // Topic format: pskr/filter/v2/{mode}/{band}/{senderCall}/{senderLoc}/{rxCall}/{rxLoc}/{freq}/{snr}
+ const txTopic = `pskr/filter/v2/+/+/${upperCallsign}/#`;
+ client.subscribe(txTopic, { qos: 0 }, (err) => {
+ if (err) {
+ console.error('[PSKReporter MQTT] TX subscribe error:', err);
+ } else {
+ console.log(`[PSKReporter MQTT] Subscribed TX: ${txTopic}`);
+ }
+ });
+
+ // Subscribe to spots where we are the receiver (hearing others)
+ const rxTopic = `pskr/filter/v2/+/+/+/+/${upperCallsign}/#`;
+ client.subscribe(rxTopic, { qos: 0 }, (err) => {
+ if (err) {
+ console.error('[PSKReporter MQTT] RX subscribe error:', err);
+ } else {
+ console.log(`[PSKReporter MQTT] Subscribed RX: ${rxTopic}`);
+ }
+ });
+ });
+
+ client.on('message', processMessage);
+
+ client.on('error', (err) => {
+ if (!mountedRef.current) return;
+ console.error('[PSKReporter MQTT] Error:', err.message);
+ setError('Connection error');
+ setConnected(false);
+ setLoading(false);
+ });
+
+ client.on('close', () => {
+ if (!mountedRef.current) return;
+ console.log('[PSKReporter MQTT] Disconnected');
+ setConnected(false);
+ });
+
+ client.on('offline', () => {
+ if (!mountedRef.current) return;
+ console.log('[PSKReporter MQTT] Offline');
+ setConnected(false);
+ setSource('offline');
+ });
+
+ client.on('reconnect', () => {
+ if (!mountedRef.current) return;
+ console.log('[PSKReporter MQTT] Reconnecting...');
+ setSource('reconnecting');
+ });
+
+ // Cleanup on unmount or callsign change
+ return () => {
+ mountedRef.current = false;
+ if (client) {
+ console.log('[PSKReporter MQTT] Cleaning up...');
+ client.end(true);
+ }
+ };
+ }, [callsign, enabled, processMessage]);
+
+ // Periodically clean old spots and update ages
+ useEffect(() => {
+ if (!enabled) return;
+
+ const interval = setInterval(() => {
+ // Update ages and clean old spots
+ const now = Date.now();
+
+ setTxReports(prev => prev.map(r => ({
+ ...r,
+ age: Math.floor((now - r.timestamp) / 60000)
+ })).filter(r => r.age <= minutes));
+
+ setRxReports(prev => prev.map(r => ({
+ ...r,
+ age: Math.floor((now - r.timestamp) / 60000)
+ })).filter(r => r.age <= minutes));
+
+ }, 30000); // Every 30 seconds
+
+ return () => clearInterval(interval);
+ }, [enabled, minutes]);
+
+ // Manual refresh - force reconnect
+ const refresh = useCallback(() => {
+ if (clientRef.current) {
+ clientRef.current.end(true);
+ clientRef.current = null;
+ }
+ setConnected(false);
+ setLoading(true);
+ setSource('reconnecting');
+ // useEffect will reconnect due to state change
+ }, []);
+
+ return {
+ txReports,
+ txCount: txReports.length,
+ rxReports,
+ rxCount: rxReports.length,
+ loading,
+ error,
+ connected,
+ source,
+ lastUpdate,
+ refresh
+ };
+};
+
+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'
}
};
diff --git a/vite.config.mjs b/vite.config.mjs
index a7bf8e7..e41bbb0 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -22,6 +22,13 @@ export default defineConfig({
'@styles': path.resolve(__dirname, './src/styles')
}
},
+ define: {
+ // mqtt.js needs these for browser
+ global: 'globalThis',
+ },
+ optimizeDeps: {
+ include: ['mqtt']
+ },
build: {
outDir: 'dist',
sourcemap: false,
@@ -29,7 +36,8 @@ export default defineConfig({
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
- satellite: ['satellite.js']
+ satellite: ['satellite.js'],
+ mqtt: ['mqtt']
}
}
}