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

451 lines
19 KiB

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

/**
* 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';
const PSKReporterPanel = ({
callsign,
onShowOnMap,
showOnMap,
onToggleMap,
filters = {},
onOpenFilters,
// WSJT-X props
wsjtxDecodes = [],
wsjtxClients = {},
wsjtxQsos = [],
wsjtxStats = {},
wsjtxLoading,
wsjtxEnabled,
wsjtxPort,
wsjtxRelayEnabled,
showWSJTXOnMap,
onToggleWSJTXMap
}) => {
const [panelMode, setPanelMode] = useState('psk');
const [activeTab, setActiveTab] = useState('tx');
const [wsjtxTab, setWsjtxTab] = useState('decodes');
const [wsjtxFilter, setWsjtxFilter] = useState('all'); // 'all' | 'cq' | band name
// 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={() => setPanelMode('psk')} style={segBtn(panelMode === 'psk', 'var(--accent-primary)')}>
PSKReporter
</button>
<button onClick={() => setPanelMode('wsjtx')} style={segBtn(panelMode === 'wsjtx', '#a78bfa')}>
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')}>
{pskFilterCount > 0 ? `🔍${pskFilterCount}` : '🔍'}
</button>
<button onClick={refresh} disabled={loading} style={{
...iconBtn(false),
opacity: loading ? 0.4 : 1,
cursor: loading ? 'not-allowed' : 'pointer',
}}>🔄</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')}>
🗺
</button>
)}
</div>
</div>
{/* ── Row 2: Sub-tabs ── */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '5px', flexShrink: 0 }}>
{panelMode === 'psk' ? (
<>
<button onClick={() => setActiveTab('tx')} style={subTabBtn(activeTab === 'tx', '#4ade80')}>
Heard ({pskFilterCount > 0 ? filteredTx.length : txCount})
</button>
<button onClick={() => setActiveTab('rx')} style={subTabBtn(activeTab === 'rx', '#60a5fa')}>
Hearing ({pskFilterCount > 0 ? filteredRx.length : rxCount})
</button>
</>
) : (
<>
<button onClick={() => setWsjtxTab('decodes')} style={subTabBtn(wsjtxTab === 'decodes', '#a78bfa')}>
Decodes ({filteredDecodes.length})
</button>
<button onClick={() => setWsjtxTab('qsos')} style={subTabBtn(wsjtxTab === 'qsos', '#a78bfa')}>
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 🔄
</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 ? (
<div style={{ fontSize: '10px', opacity: 0.6, lineHeight: 1.5 }}>
<span style={{ color: '#a78bfa' }}>Relay mode</span> run the relay agent locally:
<br />
<code style={{ background: 'rgba(167,139,250,0.15)', padding: '1px 4px', borderRadius: '2px', fontSize: '9px' }}>
node relay.js --url {'{this server}'} --key {'{key}'}
</code>
<br />
<span style={{ fontSize: '9px' }}>See wsjtx-relay/README.md</span>
</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.