// PSKReporterPanel — can the station I am about to call actually hear me? // // The decodes list to the left says who is transmitting. It cannot say anything // about the other direction, and on FT8 that is the whole question: the DX's // pileup is invisible from here, and a station whose region is not open to // yours will not hear you however many times you call. // // Every number here comes from PSK Reporter — reports uploaded by ordinary // stations saying "I decoded X" — over a five-minute window. Nothing is // inferred and nothing is remembered: when the window empties the panel says it // does not know, which is the honest answer and the reason each block also says // what it is measuring. // // The backend (internal/pskrtgt) does the analysis; this draws it and polls. import { useEffect, useMemo, useState } from 'react'; import { Activity, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { GetPSKAnalysis, SetPSKTarget } from '../../wailsjs/go/main/App'; export type PSKEntry = { call: string; grid?: string; snr: number; offset_hz: number; age_sec: number; }; export type PSKAnalysis = { target?: string; mode?: string; enabled: boolean; online: boolean; spots: number; he_me: boolean; he_me_seconds: number; he_me_snr: number; he_me_offset_hz: number; target_uploads: boolean; target_grid?: string; near_him_count: number; near_him_top?: PSKEntry[]; from_my_area_count: number; from_my_area_top?: PSKEntry[]; path_open: boolean; heard_by_count: number; heard_near_me: number; heard_near_me_top?: PSKEntry[]; decoded_by_count: number; decoded_by_top?: PSKEntry[]; decoded_by_calls?: string[]; pileup_count: number; dial_hz: number; ceiling_hz: number; decodes_in_window: number; bins?: { offset_hz: number; count: number; avg_snr: number }[]; suggested_offset: number; }; interface Props { // The station to analyse and the mode it was heard on. Set by clicking a // decode, or by whoever the digital application says it is calling. target: string; mode?: string; // The operator's own dial, which is what turns a report's frequency into an // audio offset. Without it the passband block has nothing to say. dialHz?: number; // The local decodes, for "callers you hear": stations WE are decoding that // are calling the same DX. That is the competition measured at this end, // which no amount of PSK Reporter data can show. callers: number; callerCalls?: string[]; onCollapse: () => void; } // The passband strip: 60 Hz bins, drawn from 200 Hz to 4000 Hz. The bin edges // have to match the backend's alignment exactly — it keys them on multiples of // 60 from zero, so a strip starting at 200 would ask for edges that never // exist and draw an empty histogram over a busy passband. const LO = 200, HI = 4000, STEP = 60; const FIRST_EDGE = Math.floor(LO / STEP) * STEP; const COLS = Math.floor((HI - FIRST_EDGE) / STEP); export function PSKReporterPanel({ target, mode, dialHz, callers, callerCalls, onCollapse }: Props) { const { t } = useI18n(); const [a, setA] = useState(null); // One second, matching the panel's own claim about how fresh it is. The call // is a snapshot of an in-memory window — no query and no network of its own. useEffect(() => { let stop = false; const tick = () => { GetPSKAnalysis().then((r) => { if (!stop) setA(r as unknown as PSKAnalysis); }).catch(() => {}); }; tick(); const id = window.setInterval(tick, 1000); return () => { stop = true; window.clearInterval(id); }; }, []); // The target is re-asserted rather than sent once. The backend treats an // unchanged callsign as a no-op, and this way a broker that dropped while // nobody was looking comes back on its own instead of leaving a panel that // is permanently, silently empty. useEffect(() => { SetPSKTarget(target ?? '', mode ?? '', dialHz ?? 0).catch(() => {}); if (!target) return; const id = window.setInterval(() => { SetPSKTarget(target, mode ?? '', dialHz ?? 0).catch(() => {}); }, 15000); return () => window.clearInterval(id); }, [target, mode, dialHz]); const bins = a?.bins ?? []; const maxCount = useMemo(() => bins.reduce((m, b) => Math.max(m, b.count || 0), 0) || 1, [bins]); const byOffset = useMemo(() => { const m = new Map(); for (const b of bins) m.set(b.offset_hz, b); return m; }, [bins]); const columns = useMemo(() => { const out: { edge: number; count: number; snr: number | null }[] = []; for (let i = 0; i < COLS; i++) { const edge = FIRST_EDGE + i * STEP; const b = byOffset.get(edge); out.push({ edge, count: b?.count ?? 0, snr: b?.avg_snr ?? null }); } return out; }, [byOffset]); // Confirmed pileup: a station we hear calling this DX that the DX has also // decoded. Two independent pieces of evidence, so it is the one number here // that is not a proxy for anything. const confirmed = useMemo(() => { const heard = new Set((a?.decoded_by_calls ?? []).map((c) => c.toUpperCase())); return (callerCalls ?? []).filter((c) => heard.has(c.toUpperCase())).length; }, [a?.decoded_by_calls, callerCalls]); const snr = (v: number) => `${v > 0 ? '+' : ''}${v}`; const Tile = ({ label, value, foot, tone, title }: { label: string; value: number | string; foot: string; tone: string; title?: string; }) => (
{label}
{value}
{foot}
); return (
{/* Header: what is being watched, and whether the feed is actually up. A panel full of zeros means one of two very different things. */}
{t('psk.title')} {target && → {target}} {a?.target && a.spots > 0 && ( {t('psk.spots', { n: a.spots })} )} {a?.enabled === false ? {t('psk.off')} : a?.online ? ● {t('psk.online')} : ○ {t('psk.offline')}}
{a?.enabled === false ? (

{t('psk.enableHint')}

) : !target ? (

{t('psk.pickHint')}

) : ( <> {/* ── The answer ──────────────────────────────────────────── */}
{a?.he_me ? '✓' : a?.path_open ? '≈' : '·'}
{a?.he_me ? ( <>
{t('psk.heardYou', { s: a.he_me_seconds })}
{snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''}
) : a?.path_open ? ( <>
{t('psk.pathOpen')}
{t('psk.pathOpenSub', { n: a.from_my_area_count })}
) : ( <>
{t('psk.notYet')}
{t('psk.notYetSub')}
)}
{/* Your signal reported next to him. Only when he has not decoded you himself — that is strictly stronger evidence, and two banners saying the same thing differently is noise. */} {!a?.he_me && (a?.near_him_count ?? 0) > 0 && a?.target_grid && (
✓ {t('psk.nearHim', { g: a.target_grid })} {t('psk.nRx', { n: a.near_him_count })}
{(a.near_him_top ?? []).map((h) => ( {h.call} {snr(h.snr)} ))}
)} {/* ── The four numbers ────────────────────────────────────── */}
`${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} /> 0 ? `${callers} (${confirmed})` : callers} foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')} tone="text-warning" title={t('psk.tCallersTip')} />
{/* The one thing that turns an empty panel from a verdict into a missing measurement. */} {a?.target_uploads ? (
✓ {t('psk.uploads')}
) : (
⚠ {t('psk.noUploads', { c: target })}
)} {/* ── Who near you he is hearing ──────────────────────────── */}
{t('psk.fromAreaList')}
{(a?.from_my_area_top ?? []).length > 0 ? (
{(a?.from_my_area_top ?? []).slice(0, 4).map((h) => (
{h.call} ({(h.grid ?? '?').slice(0, 4)}) {snr(h.snr)} dB {h.offset_hz > 0 && h.offset_hz < 10000 && ( @ +{h.offset_hz} Hz )}
))}
) : (
{t('psk.fromAreaEmpty')}
)}
{/* ── His passband ────────────────────────────────────────── */}
{t('psk.passband')} {(a?.ceiling_hz ?? 0) > 0 ? t('psk.ceiling', { hz: a!.ceiling_hz, n: a!.decodes_in_window }) : (a?.decodes_in_window ?? 0) > 0 ? t('psk.noDial') : t('psk.noDecodes')}
{columns.map((c) => { const ratio = c.count / maxCount; return (
0.66 ? 'bg-primary' : ratio > 0.33 ? 'bg-primary/70' : 'bg-primary/40')} style={{ height: `${Math.max(2, Math.round(ratio * 36))}px` }} title={`${c.edge}-${c.edge + STEP} Hz · ${c.count}${c.snr !== null ? ` @ ${c.snr.toFixed(0)} dB` : ''}`} /> ); })} {(a?.suggested_offset ?? 0) > 0 && (
)}
{[1000, 2000, 3000, 4000].map((hz) => ( {hz} ))}
{(a?.suggested_offset ?? 0) > 0 && (
🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })}
)}
)}
); }