/** * PSKReporter + WSJT-X Panel * Digital mode spots — toggle between internet (PSKReporter) and local (WSJT-X UDP) * * Layout: * Row 1: Segmented mode toggle | map + filter controls * Row 2: Sub-tabs (Being Heard / Hearing or Decodes / QSOs) * Content: Scrolling spot/decode list */ import React, { useState, useMemo } from 'react'; import { usePSKReporter } from '../hooks/usePSKReporter.js'; import { getBandColor } from '../utils/callsign.js'; import { IconSearch, IconRefresh, IconMap } from './Icons.jsx'; const PSKReporterPanel = ({ callsign, onShowOnMap, showOnMap, onToggleMap, filters = {}, onOpenFilters, // WSJT-X props wsjtxDecodes = [], wsjtxClients = {}, wsjtxQsos = [], wsjtxStats = {}, wsjtxLoading, wsjtxEnabled, wsjtxPort, wsjtxRelayEnabled, wsjtxRelayConnected, wsjtxSessionId, showWSJTXOnMap, onToggleWSJTXMap }) => { const [panelMode, setPanelMode] = useState(() => { try { const s = localStorage.getItem('openhamclock_pskPanelMode'); return s || 'psk'; } catch { return 'psk'; } }); const [activeTab, setActiveTab] = useState(() => { try { const s = localStorage.getItem('openhamclock_pskActiveTab'); return s || 'tx'; } catch { return 'tx'; } }); const [wsjtxTab, setWsjtxTab] = useState('decodes'); const [wsjtxFilter, setWsjtxFilter] = useState('all'); // 'all' | 'cq' | band name // Persist panel mode and active tab const setPanelModePersist = (v) => { setPanelMode(v); try { localStorage.setItem('openhamclock_pskPanelMode', v); } catch {} }; const setActiveTabPersist = (v) => { setActiveTab(v); try { localStorage.setItem('openhamclock_pskActiveTab', v); } catch {} }; // PSKReporter hook const { txReports, txCount, rxReports, rxCount, loading, error, connected, source, refresh } = usePSKReporter(callsign, { minutes: 15, enabled: callsign && callsign !== 'N0CALL' }); // ── PSK filtering ── const filterReports = (reports) => { return reports.filter(r => { if (filters?.bands?.length && !filters.bands.includes(r.band)) return false; if (filters?.grids?.length) { const grid = activeTab === 'tx' ? r.receiverGrid : r.senderGrid; if (!grid) return false; if (!filters.grids.includes(grid.substring(0, 2).toUpperCase())) return false; } 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; const pskFilterCount = [filters?.bands?.length, filters?.grids?.length, filters?.modes?.length].filter(Boolean).length; const getFreqColor = (freqMHz) => !freqMHz ? 'var(--text-muted)' : getBandColor(parseFloat(freqMHz)); const formatAge = (m) => m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m/60)}h`; // ── WSJT-X helpers ── const activeClients = Object.entries(wsjtxClients); const primaryClient = activeClients[0]?.[1] || null; // Build unified filter options: All, CQ Only, then each available band const wsjtxFilterOptions = useMemo(() => { const bands = [...new Set(wsjtxDecodes.map(d => d.band).filter(Boolean))] .sort((a, b) => (parseInt(b) || 999) - (parseInt(a) || 999)); return [ { value: 'all', label: 'All decodes' }, { value: 'cq', label: 'CQ only' }, ...bands.map(b => ({ value: b, label: b })) ]; }, [wsjtxDecodes]); const filteredDecodes = useMemo(() => { let filtered = [...wsjtxDecodes]; if (wsjtxFilter === 'cq') { filtered = filtered.filter(d => d.type === 'CQ'); } else if (wsjtxFilter !== 'all') { // Band filter filtered = filtered.filter(d => d.band === wsjtxFilter); } return filtered.reverse(); }, [wsjtxDecodes, wsjtxFilter]); const getSnrColor = (snr) => { if (snr == null) return 'var(--text-muted)'; if (snr >= 0) return '#4ade80'; if (snr >= -10) return '#fbbf24'; if (snr >= -18) return '#fb923c'; return '#ef4444'; }; const getMsgColor = (d) => { if (d.type === 'CQ') return '#60a5fa'; if (['RR73', '73', 'RRR'].includes(d.exchange)) return '#4ade80'; if (d.exchange?.startsWith('R')) return '#fbbf24'; return 'var(--text-primary)'; }; // Active map toggle for current mode const isMapOn = panelMode === 'psk' ? showOnMap : showWSJTXOnMap; const handleMapToggle = panelMode === 'psk' ? onToggleMap : onToggleWSJTXMap; // Compact status dot const statusDot = connected ? { color: '#4ade80', char: '●' } : (source === 'connecting' || source === 'reconnecting') ? { color: '#fbbf24', char: '◐' } : error ? { color: '#ef4444', char: '●' } : null; // ── Shared styles ── const segBtn = (active, color) => ({ padding: '3px 10px', background: active ? `${color}18` : 'transparent', color: active ? color : 'var(--text-muted)', border: 'none', borderBottom: active ? `2px solid ${color}` : '2px solid transparent', fontSize: '11px', fontWeight: active ? '700' : '400', cursor: 'pointer', letterSpacing: '0.02em', }); const subTabBtn = (active, color) => ({ flex: 1, padding: '3px 4px', background: active ? `${color}20` : 'transparent', border: `1px solid ${active ? color + '66' : 'var(--border-color)'}`, borderRadius: '3px', color: active ? color : 'var(--text-muted)', cursor: 'pointer', fontSize: '10px', fontWeight: active ? '600' : '400', }); const iconBtn = (active, activeColor = '#4488ff') => ({ background: active ? `${activeColor}30` : 'rgba(100,100,100,0.3)', border: `1px solid ${active ? activeColor : '#555'}`, color: active ? activeColor : '#777', padding: '2px 6px', borderRadius: '3px', fontSize: '10px', cursor: 'pointer', lineHeight: 1, }); return (
{/* ── Row 1: Mode toggle + controls ── */}
{/* Mode toggle */}
{/* Right controls */}
{/* PSK: status dot + filter + refresh */} {panelMode === 'psk' && ( <> {statusDot && ( {statusDot.char} )} )} {/* WSJT-X: mode/band info + unified filter */} {panelMode === 'wsjtx' && ( <> {primaryClient && ( {primaryClient.mode} {primaryClient.band} {primaryClient.transmitting && TX} )} )} {/* Map toggle (always visible) */} {handleMapToggle && ( )}
{/* ── Row 2: Sub-tabs ── */}
{panelMode === 'psk' ? ( <> ) : ( <> )}
{/* ── Content area ── */}
{/* === PSKReporter content === */} {panelMode === 'psk' && ( <> {(!callsign || callsign === 'N0CALL') ? (
Set your callsign in Settings to see reports
) : error && !connected ? (
Connection failed — tap refresh ↻
) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? (
Connecting...
) : filteredReports.length === 0 ? (
{pskFilterCount > 0 ? 'No spots match filters' : activeTab === 'tx' ? 'Waiting for spots... (TX to see reports)' : 'No stations heard yet'}
) : ( filteredReports.slice(0, 25).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)} style={{ display: 'grid', gridTemplateColumns: '52px 1fr auto', gap: '5px', padding: '3px 4px', borderRadius: '2px', background: i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent', cursor: report.lat && report.lon ? 'pointer' : 'default', }} onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(68,136,255,0.12)'} onMouseLeave={(e) => e.currentTarget.style.background = i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'} > {freqMHz} {displayCall} {grid && {grid}} {report.mode} {report.snr != null && ( = 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}> {report.snr > 0 ? '+' : ''}{report.snr} )} {formatAge(report.age)}
); }) )} )} {/* === WSJT-X content === */} {panelMode === 'wsjtx' && ( <> {/* No client connected */} {!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? (
Waiting for WSJT-X...
{wsjtxRelayEnabled ? ( wsjtxRelayConnected ? (
Relay connected
WSJT-X decodes will appear here when the station is active
) : (
Download the relay agent for your PC:
Requires Node.js · Run the script, then start WSJT-X
) ) : (
In WSJT-X: Settings → Reporting → UDP Server
Address: 127.0.0.1   Port: {wsjtxPort || 2237}
)}
) : wsjtxTab === 'decodes' ? ( <> {filteredDecodes.length === 0 ? (
{wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening...'}
) : ( filteredDecodes.map((d, i) => (
{d.time} {d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''} {d.dt} {d.freq} {d.message}
)) )} ) : ( /* QSOs tab */ <> {wsjtxQsos.length === 0 ? (
No QSOs logged yet
) : ( [...wsjtxQsos].reverse().map((q, i) => (
{q.dxCall} {q.band} {q.mode} {q.reportSent}/{q.reportRecv} {q.dxGrid && {q.dxGrid}}
)) )} )} )}
{/* ── WSJT-X status footer ── */} {panelMode === 'wsjtx' && activeClients.length > 0 && (
{activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')} {primaryClient?.dialFrequency && ( {(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz )}
)}
); }; export default PSKReporterPanel; export { PSKReporterPanel };