You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
openhamclock/src/components/PSKReporterPanel.jsx

493 lines
22 KiB

/**
* 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 (
<div className="panel" style={{
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden'
}}>
{/* ── Row 1: Mode toggle + controls ── */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '5px',
flexShrink: 0,
}}>
{/* Mode toggle */}
<div style={{ display: 'flex' }}>
<button onClick={() => setPanelModePersist('psk')} style={segBtn(panelMode === 'psk', 'var(--accent-primary)')} title="Internet-based reception reports via PSKReporter.info">
PSKReporter
</button>
<button onClick={() => setPanelModePersist('wsjtx')} style={segBtn(panelMode === 'wsjtx', '#a78bfa')} title="Local WSJT-X decodes via UDP relay">
WSJT-X
</button>
</div>
{/* Right controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{/* PSK: status dot + filter + refresh */}
{panelMode === 'psk' && (
<>
{statusDot && (
<span style={{ color: statusDot.color, fontSize: '10px', lineHeight: 1 }}>{statusDot.char}</span>
)}
<button onClick={onOpenFilters} style={iconBtn(pskFilterCount > 0, '#ffaa00')} title="Filter spots by band, mode, or grid">
<IconSearch size={11} style={{ verticalAlign: 'middle' }} />{pskFilterCount > 0 ? pskFilterCount : ''}
</button>
<button onClick={refresh} disabled={loading} style={{
...iconBtn(false),
opacity: loading ? 0.4 : 1,
cursor: loading ? 'not-allowed' : 'pointer',
}} title="Reconnect to PSKReporter"><IconRefresh size={11} style={{ verticalAlign: 'middle' }} /></button>
</>
)}
{/* WSJT-X: mode/band info + unified filter */}
{panelMode === 'wsjtx' && (
<>
{primaryClient && (
<span style={{ fontSize: '9px', color: 'var(--text-muted)' }}>
{primaryClient.mode} {primaryClient.band}
{primaryClient.transmitting && <span style={{ color: '#ef4444', marginLeft: '2px' }}>TX</span>}
</span>
)}
<select
value={wsjtxFilter}
onChange={(e) => setWsjtxFilter(e.target.value)}
style={{
background: 'var(--bg-tertiary)',
color: wsjtxFilter !== 'all' ? '#a78bfa' : 'var(--text-primary)',
border: `1px solid ${wsjtxFilter !== 'all' ? '#a78bfa55' : 'var(--border-color)'}`,
borderRadius: '3px',
fontSize: '10px',
padding: '1px 4px',
cursor: 'pointer',
maxWidth: '90px',
}}
>
{wsjtxFilterOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</>
)}
{/* Map toggle (always visible) */}
{handleMapToggle && (
<button onClick={handleMapToggle} style={iconBtn(isMapOn, panelMode === 'psk' ? '#4488ff' : '#a78bfa')} title={isMapOn ? 'Hide spots on map' : 'Show spots on map'}>
<IconMap size={11} style={{ verticalAlign: 'middle' }} />
</button>
)}
</div>
</div>
{/* ── Row 2: Sub-tabs ── */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '5px', flexShrink: 0 }}>
{panelMode === 'psk' ? (
<>
<button onClick={() => setActiveTabPersist('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')} title="Stations hearing your signal">
Heard ({pskFilterCount > 0 ? filteredTx.length : txCount})
</button>
<button onClick={() => setActiveTabPersist('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')} title="Stations you are hearing">
Hearing ({pskFilterCount > 0 ? filteredRx.length : rxCount})
</button>
</>
) : (
<>
<button onClick={() => setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')} title="Live WSJT-X decodes">
Decodes ({filteredDecodes.length})
</button>
<button onClick={() => setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')} title="Logged QSOs from WSJT-X">
QSOs ({wsjtxQsos.length})
</button>
</>
)}
</div>
{/* ── Content area ── */}
<div style={{ flex: 1, overflow: 'auto', fontSize: '11px', fontFamily: "'JetBrains Mono', monospace" }}>
{/* === PSKReporter content === */}
{panelMode === 'psk' && (
<>
{(!callsign || callsign === 'N0CALL') ? (
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
Set your callsign in Settings to see reports
</div>
) : error && !connected ? (
<div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}>
Connection failed tap refresh
</div>
) : loading && filteredReports.length === 0 && pskFilterCount === 0 ? (
<div style={{ textAlign: 'center', padding: '16px', color: 'var(--text-muted)', fontSize: '11px' }}>
<div className="loading-spinner" style={{ margin: '0 auto 8px' }} />
Connecting...
</div>
) : filteredReports.length === 0 ? (
<div style={{ textAlign: 'center', padding: '12px', color: 'var(--text-muted)', fontSize: '11px' }}>
{pskFilterCount > 0
? 'No spots match filters'
: activeTab === 'tx'
? 'Waiting for spots... (TX to see reports)'
: 'No stations heard yet'}
</div>
) : (
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 (
<div
key={`${displayCall}-${report.freq}-${i}`}
onClick={() => 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'}
>
<span style={{ color, fontWeight: '600', fontSize: '10px' }}>{freqMHz}</span>
<span style={{
color: 'var(--text-primary)', fontWeight: '600', fontSize: '11px',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
}}>
{displayCall}
{grid && <span style={{ color: 'var(--text-muted)', fontWeight: '400', marginLeft: '4px', fontSize: '9px' }}>{grid}</span>}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '3px', fontSize: '9px' }}>
<span style={{ color: 'var(--text-muted)' }}>{report.mode}</span>
{report.snr != null && (
<span style={{ color: report.snr >= 0 ? '#4ade80' : report.snr >= -10 ? '#fbbf24' : '#f97316', fontWeight: '600' }}>
{report.snr > 0 ? '+' : ''}{report.snr}
</span>
)}
<span style={{ color: 'var(--text-muted)' }}>{formatAge(report.age)}</span>
</span>
</div>
);
})
)}
</>
)}
{/* === WSJT-X content === */}
{panelMode === 'wsjtx' && (
<>
{/* No client connected */}
{!wsjtxLoading && activeClients.length === 0 && wsjtxDecodes.length === 0 ? (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: '8px', color: 'var(--text-muted)',
fontSize: '11px', textAlign: 'center', padding: '16px 8px', height: '100%'
}}>
<div style={{ fontSize: '12px' }}>Waiting for WSJT-X...</div>
{wsjtxRelayEnabled ? (
wsjtxRelayConnected ? (
<div style={{ fontSize: '10px', opacity: 0.8, lineHeight: 1.6 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px', marginBottom: '4px' }}>
<span style={{ display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%', background: '#4ade80', boxShadow: '0 0 4px #4ade80' }} />
<span style={{ color: '#4ade80', fontWeight: 600 }}>Relay connected</span>
</div>
<div style={{ fontSize: '9px', opacity: 0.5 }}>
WSJT-X decodes will appear here when the station is active
</div>
</div>
) : (
<div style={{ fontSize: '10px', opacity: 0.8, lineHeight: 1.6 }}>
<div style={{ marginBottom: '8px' }}>
Download the relay agent for your PC:
</div>
<div style={{ display: 'flex', gap: '4px', justifyContent: 'center', flexWrap: 'wrap' }}>
<a href={`/api/wsjtx/relay/download/linux?session=${wsjtxSessionId || ''}`}
style={{
padding: '4px 10px', borderRadius: '4px', fontSize: '10px', fontWeight: '600',
background: 'rgba(167,139,250,0.2)', border: '1px solid #a78bfa55',
color: '#a78bfa', textDecoration: 'none', cursor: 'pointer',
}}>🐧 Linux</a>
<a href={`/api/wsjtx/relay/download/mac?session=${wsjtxSessionId || ''}`}
style={{
padding: '4px 10px', borderRadius: '4px', fontSize: '10px', fontWeight: '600',
background: 'rgba(167,139,250,0.2)', border: '1px solid #a78bfa55',
color: '#a78bfa', textDecoration: 'none', cursor: 'pointer',
}}>🍎 Mac</a>
<a href={`/api/wsjtx/relay/download/windows?session=${wsjtxSessionId || ''}`}
style={{
padding: '4px 10px', borderRadius: '4px', fontSize: '10px', fontWeight: '600',
background: 'rgba(167,139,250,0.2)', border: '1px solid #a78bfa55',
color: '#a78bfa', textDecoration: 'none', cursor: 'pointer',
}}>🪟 Windows</a>
</div>
<div style={{ fontSize: '9px', opacity: 0.5, marginTop: '6px' }}>
Requires Node.js · Run the script, then start WSJT-X
</div>
</div>
)
) : (
<div style={{ fontSize: '10px', opacity: 0.6, lineHeight: 1.5 }}>
In WSJT-X: Settings Reporting UDP Server
<br />
Address: 127.0.0.1 &nbsp; Port: {wsjtxPort || 2237}
</div>
)}
</div>
) : wsjtxTab === 'decodes' ? (
<>
{filteredDecodes.length === 0 ? (
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
{wsjtxDecodes.length > 0 ? 'No decodes match filter' : 'Listening...'}
</div>
) : (
filteredDecodes.map((d, i) => (
<div
key={d.id || i}
style={{
display: 'flex', gap: '5px', padding: '2px 2px',
borderBottom: '1px solid var(--border-color)',
alignItems: 'baseline',
opacity: d.lowConfidence ? 0.5 : 1,
}}
>
<span style={{ color: 'var(--text-muted)', minWidth: '44px', fontSize: '10px' }}>{d.time}</span>
<span style={{ color: getSnrColor(d.snr), minWidth: '26px', textAlign: 'right', fontSize: '10px' }}>
{d.snr != null ? (d.snr >= 0 ? `+${d.snr}` : d.snr) : ''}
</span>
<span style={{ color: 'var(--text-muted)', minWidth: '24px', textAlign: 'right', fontSize: '10px' }}>{d.dt}</span>
<span style={{
color: d.band ? getBandColor(d.dialFrequency / 1000000) : 'var(--text-muted)',
minWidth: '32px', textAlign: 'right', fontSize: '10px'
}}>{d.freq}</span>
<span style={{
color: getMsgColor(d), flex: 1, whiteSpace: 'nowrap',
overflow: 'hidden', textOverflow: 'ellipsis',
}}>{d.message}</span>
</div>
))
)}
</>
) : (
/* QSOs tab */
<>
{wsjtxQsos.length === 0 ? (
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '11px' }}>
No QSOs logged yet
</div>
) : (
[...wsjtxQsos].reverse().map((q, i) => (
<div key={i} style={{
display: 'flex', gap: '5px', padding: '3px 2px',
borderBottom: '1px solid var(--border-color)', alignItems: 'baseline',
}}>
<span style={{
color: q.band ? getBandColor(q.frequency / 1000000) : 'var(--accent-green)',
fontWeight: '600', minWidth: '65px'
}}>{q.dxCall}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.band}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.mode}</span>
<span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{q.reportSent}/{q.reportRecv}</span>
{q.dxGrid && <span style={{ color: '#a78bfa', fontSize: '10px' }}>{q.dxGrid}</span>}
</div>
))
)}
</>
)}
</>
)}
</div>
{/* ── WSJT-X status footer ── */}
{panelMode === 'wsjtx' && activeClients.length > 0 && (
<div style={{
fontSize: '9px', color: 'var(--text-muted)',
borderTop: '1px solid var(--border-color)',
paddingTop: '2px', marginTop: '2px',
display: 'flex', justifyContent: 'space-between', flexShrink: 0
}}>
<span>{activeClients.map(([id, c]) => `${id}${c.version ? ` v${c.version}` : ''}`).join(', ')}</span>
{primaryClient?.dialFrequency && (
<span style={{ color: '#a78bfa' }}>{(primaryClient.dialFrequency / 1000000).toFixed(6)} MHz</span>
)}
</div>
)}
</div>
);
};
export default PSKReporterPanel;
export { PSKReporterPanel };

Powered by TurnKey Linux.