diff --git a/src/App.jsx b/src/App.jsx index 885e60c..2aa0d9e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -13,6 +13,7 @@ import { ContestPanel, SettingsPanel, DXFilterManager, + PSKFilterManager, SolarPanel, PropagationPanel, DXpeditionPanel, @@ -96,6 +97,7 @@ const App = () => { // UI state const [showSettings, setShowSettings] = useState(false); const [showDXFilters, setShowDXFilters] = useState(false); + const [showPSKFilters, setShowPSKFilters] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); // Map layer visibility @@ -183,6 +185,20 @@ const App = () => { } catch (e) {} }, [dxFilters]); + // PSKReporter Filters + const [pskFilters, setPskFilters] = useState(() => { + try { + const stored = localStorage.getItem('openhamclock_pskFilters'); + return stored ? JSON.parse(stored) : {}; + } catch (e) { return {}; } + }); + + useEffect(() => { + try { + localStorage.setItem('openhamclock_pskFilters', JSON.stringify(pskFilters)); + } catch (e) {} + }, [pskFilters]); + const dxCluster = useDXCluster(config.dxClusterSource || 'auto', dxFilters); const dxPaths = useDXPaths(); const dxpeditions = useDXpeditions(); @@ -193,6 +209,25 @@ const App = () => { const localWeather = useLocalWeather(config.location); const pskReporter = usePSKReporter(config.callsign, { minutes: 15, enabled: config.callsign !== 'N0CALL' }); + // Filter PSKReporter spots for map display + const filteredPskSpots = useMemo(() => { + const allSpots = [...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]; + if (!pskFilters?.bands?.length && !pskFilters?.grids?.length && !pskFilters?.modes?.length) { + return allSpots; + } + return allSpots.filter(spot => { + if (pskFilters?.bands?.length && !pskFilters.bands.includes(spot.band)) return false; + if (pskFilters?.modes?.length && !pskFilters.modes.includes(spot.mode)) return false; + if (pskFilters?.grids?.length) { + const grid = spot.receiverGrid || spot.senderGrid; + if (!grid) return false; + const gridPrefix = grid.substring(0, 2).toUpperCase(); + if (!pskFilters.grids.includes(gridPrefix)) return false; + } + return true; + }); + }, [pskReporter.txReports, pskReporter.rxReports, pskFilters]); + // Computed values const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]); const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]); @@ -461,7 +496,7 @@ const App = () => { dxPaths={dxPaths.data} dxFilters={dxFilters} satellites={satellites.data} - pskReporterSpots={[...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]} + pskReporterSpots={filteredPskSpots} showDXPaths={mapLayers.showDXPaths} showDXLabels={mapLayers.showDXLabels} onToggleDXLabels={toggleDXLabels} @@ -599,7 +634,7 @@ const App = () => { dxPaths={dxPaths.data} dxFilters={dxFilters} satellites={satellites.data} - pskReporterSpots={[...(pskReporter.txReports || []), ...(pskReporter.rxReports || [])]} + pskReporterSpots={filteredPskSpots} showDXPaths={mapLayers.showDXPaths} showDXLabels={mapLayers.showDXLabels} onToggleDXLabels={toggleDXLabels} @@ -648,6 +683,8 @@ const App = () => { callsign={config.callsign} showOnMap={mapLayers.showPSKReporter} onToggleMap={togglePSKReporter} + filters={pskFilters} + onOpenFilters={() => setShowPSKFilters(true)} onShowOnMap={(report) => { if (report.lat && report.lon) { setDxLocation({ lat: report.lat, lon: report.lon, call: report.receiver || report.sender }); @@ -692,6 +729,12 @@ const App = () => { isOpen={showDXFilters} onClose={() => setShowDXFilters(false)} /> + setShowPSKFilters(false)} + /> ); }; 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 + +
+ + +
+
+
+ {BANDS.map(band => ( + + ))} +
+
+ {filters?.bands?.length + ? `Showing only: ${filters.bands.join(', ')}` + : 'Showing all bands (no filter)'} +
+
+ ); + + const renderGridsTab = () => ( +
+
+ + Filter by Grid Square + + +
+ + {/* 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' + }} + /> + +
+ + {/* Selected grids */} + {filters?.grids?.length > 0 && ( +
+
+ Active Grid Filters: +
+
+ {filters.grids.map(grid => ( + + ))} +
+
+ )} + + {/* Quick select by region */} +
+ Quick Select by Region: +
+ {GRID_REGIONS.map(region => ( +
+
+ {region.name} +
+
+ {region.grids.map(grid => ( + + ))} +
+
+ ))} +
+ ); + + const renderModesTab = () => ( +
+
+ + Filter by Mode + +
+ + +
+
+
+ {MODES.map(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 */} +
+ + + +
+ + {/* Tab Content */} +
+ {activeTab === 'bands' && renderBandsTab()} + {activeTab === 'grids' && renderGridsTab()} + {activeTab === 'modes' && renderModesTab()} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default PSKFilterManager; diff --git a/src/components/PSKReporterPanel.jsx b/src/components/PSKReporterPanel.jsx index dda1c82..d7fff17 100644 --- a/src/components/PSKReporterPanel.jsx +++ b/src/components/PSKReporterPanel.jsx @@ -3,12 +3,18 @@ * Shows where your digital mode signals are being received * Uses MQTT WebSocket for real-time data */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { usePSKReporter } from '../hooks/usePSKReporter.js'; import { getBandColor } from '../utils/callsign.js'; -const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => { - const [timeWindow] = useState(15); // Keep spots for 15 minutes +const PSKReporterPanel = ({ + callsign, + onShowOnMap, + showOnMap, + onToggleMap, + filters = {}, + onOpenFilters +}) => { const [activeTab, setActiveTab] = useState('tx'); // Default to 'tx' (Being Heard) const { @@ -22,11 +28,44 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => source, refresh } = usePSKReporter(callsign, { - minutes: timeWindow, + minutes: 15, enabled: callsign && callsign !== 'N0CALL' }); - const reports = activeTab === 'tx' ? txReports : rxReports; + // 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) => { @@ -77,7 +116,7 @@ const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap }) => height: '100%', overflow: 'hidden' }}> - {/* Header - matches DX Cluster style */} + {/* Header */}
}}> 📡 PSKReporter {getStatusIndicator()}
+ + {filteredReports.length}/{activeTab === 'tx' ? txCount : rxCount} + +
- {/* Tabs - compact style */} + {/* Tabs */}
fontFamily: 'JetBrains Mono' }} > - 📤 Being Heard ({txCount}) + 📤 Being Heard ({filterCount > 0 ? `${filteredTx.length}` : txCount})
- {/* Reports list - matches DX Cluster style */} + {/* Reports list */} {error && !connected ? (
⚠️ Connection failed - click 🔄 to retry
- ) : loading && reports.length === 0 ? ( + ) : loading && filteredReports.length === 0 && filterCount === 0 ? (
Connecting to MQTT...
- ) : !connected && reports.length === 0 ? ( + ) : !connected && filteredReports.length === 0 && filterCount === 0 ? (
Waiting for connection...
- ) : reports.length === 0 ? ( + ) : filteredReports.length === 0 ? (
- {activeTab === 'tx' - ? 'Waiting for spots... (TX to see reports)' - : 'No stations heard yet'} + {filterCount > 0 + ? 'No spots match filters' + : activeTab === 'tx' + ? 'Waiting for spots... (TX to see reports)' + : 'No stations heard yet'}
) : (
fontSize: '12px', fontFamily: 'JetBrains Mono, monospace' }}> - {reports.slice(0, 20).map((report, i) => { + {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; diff --git a/src/components/index.js b/src/components/index.js index 7d84316..3093e72 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -13,6 +13,7 @@ export { ContestPanel } from './ContestPanel.jsx'; export { LocationPanel } from './LocationPanel.jsx'; export { SettingsPanel } from './SettingsPanel.jsx'; export { DXFilterManager } from './DXFilterManager.jsx'; +export { PSKFilterManager } from './PSKFilterManager.jsx'; export { SolarPanel } from './SolarPanel.jsx'; export { PropagationPanel } from './PropagationPanel.jsx'; export { DXpeditionPanel } from './DXpeditionPanel.jsx';