330 lines
16 KiB
TypeScript
330 lines
16 KiB
TypeScript
// 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<PSKAnalysis | null>(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<number, { count: number; avg_snr: number }>();
|
|
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;
|
|
}) => (
|
|
<div className="px-2 py-1.5 rounded-md bg-muted/40 border border-border/60" title={title}>
|
|
<div className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
|
<div className={cn('text-lg font-bold leading-tight', tone)}>{value}</div>
|
|
<div className="text-[10px] text-muted-foreground">{foot}</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="w-[340px] shrink-0 flex flex-col min-h-0 border-l border-border bg-card">
|
|
{/* Header: what is being watched, and whether the feed is actually up. A
|
|
panel full of zeros means one of two very different things. */}
|
|
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border shrink-0">
|
|
<Activity className="size-4 text-primary shrink-0" />
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
{t('psk.title')}
|
|
</span>
|
|
{target && <span className="text-xs font-mono text-foreground truncate">→ {target}</span>}
|
|
<span className="ml-auto flex items-center gap-2 text-[10px] shrink-0">
|
|
{a?.target && a.spots > 0 && (
|
|
<span className="text-muted-foreground" title={t('psk.spotsTip')}>{t('psk.spots', { n: a.spots })}</span>
|
|
)}
|
|
{a?.enabled === false
|
|
? <span className="text-muted-foreground">{t('psk.off')}</span>
|
|
: a?.online
|
|
? <span className="text-success">● {t('psk.online')}</span>
|
|
: <span className="text-muted-foreground">○ {t('psk.offline')}</span>}
|
|
<button type="button" onClick={onCollapse} title={t('psk.hide')}
|
|
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground">
|
|
<ChevronRight className="size-4" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-2.5 py-2 space-y-3">
|
|
{a?.enabled === false ? (
|
|
<p className="text-xs text-muted-foreground italic">{t('psk.enableHint')}</p>
|
|
) : !target ? (
|
|
<p className="text-xs text-muted-foreground italic">{t('psk.pickHint')}</p>
|
|
) : (
|
|
<>
|
|
{/* ── The answer ──────────────────────────────────────────── */}
|
|
<div className="flex items-center gap-3">
|
|
<div className={cn('shrink-0 size-8 rounded-full border flex items-center justify-center text-base',
|
|
a?.he_me ? 'bg-success/20 border-success/50 text-success'
|
|
: a?.path_open ? 'bg-warning/20 border-warning/50 text-warning'
|
|
: 'bg-muted border-border text-muted-foreground')}>
|
|
{a?.he_me ? '✓' : a?.path_open ? '≈' : '·'}
|
|
</div>
|
|
<div className="min-w-0">
|
|
{a?.he_me ? (
|
|
<>
|
|
<div className="text-sm font-semibold text-success">{t('psk.heardYou', { s: a.he_me_seconds })}</div>
|
|
<div className="text-[11px] font-mono text-muted-foreground">
|
|
{snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''}
|
|
</div>
|
|
</>
|
|
) : a?.path_open ? (
|
|
<>
|
|
<div className="text-sm font-semibold text-warning">{t('psk.pathOpen')}</div>
|
|
<div className="text-[11px] text-muted-foreground">{t('psk.pathOpenSub', { n: a.from_my_area_count })}</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="text-sm font-semibold text-muted-foreground">{t('psk.notYet')}</div>
|
|
<div className="text-[11px] text-muted-foreground">{t('psk.notYetSub')}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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 && (
|
|
<div className="px-2 py-1.5 rounded-md bg-info/10 border border-info/30">
|
|
<div className="flex items-baseline justify-between gap-2 mb-0.5">
|
|
<span className="text-[10px] uppercase tracking-wide font-semibold text-info">
|
|
✓ {t('psk.nearHim', { g: a.target_grid })}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">{t('psk.nRx', { n: a.near_him_count })}</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-2 font-mono text-[11px]">
|
|
{(a.near_him_top ?? []).map((h) => (
|
|
<span key={h.call} className="text-info" title={`${h.call} ${h.grid ?? ''} · ${h.age_sec}s`}>
|
|
{h.call} <span className="text-muted-foreground">{snr(h.snr)}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── The four numbers ────────────────────────────────────── */}
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
<Tile label={t('psk.tFromArea')} value={a?.from_my_area_count ?? 0} foot={t('psk.tFromAreaFoot')}
|
|
tone="text-success"
|
|
title={(a?.from_my_area_top ?? []).map((h) => `${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} />
|
|
<Tile label={t('psk.tPileup')} value={a?.pileup_count ?? 0} foot={t('psk.tPileupFoot')}
|
|
tone="text-primary" title={t('psk.tPileupTip')} />
|
|
<Tile label={t('psk.tHeardNear')} value={a?.heard_near_me ?? 0} foot={t('psk.tHeardNearFoot')}
|
|
tone="text-info"
|
|
title={t('psk.tHeardNearTip', { n: a?.heard_by_count ?? 0 })} />
|
|
<Tile label={t('psk.tCallers')} value={confirmed > 0 ? `${callers} (${confirmed})` : callers}
|
|
foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')}
|
|
tone="text-warning" title={t('psk.tCallersTip')} />
|
|
</div>
|
|
|
|
{/* The one thing that turns an empty panel from a verdict into a
|
|
missing measurement. */}
|
|
{a?.target_uploads ? (
|
|
<div className="text-[11px] text-success">✓ {t('psk.uploads')}</div>
|
|
) : (
|
|
<div className="px-2 py-1.5 rounded-md bg-warning/10 border border-warning/30 text-[11px] text-warning">
|
|
⚠ {t('psk.noUploads', { c: target })}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Who near you he is hearing ──────────────────────────── */}
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">{t('psk.fromAreaList')}</div>
|
|
{(a?.from_my_area_top ?? []).length > 0 ? (
|
|
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
|
{(a?.from_my_area_top ?? []).slice(0, 4).map((h) => (
|
|
<div key={h.call} className="flex items-baseline gap-2 truncate"
|
|
title={t('psk.rowTip', { c: h.call, g: h.grid ?? '?', s: h.age_sec, d: snr(h.snr) })}>
|
|
<span className="font-semibold text-foreground w-20 truncate">{h.call}</span>
|
|
<span className="text-muted-foreground w-12">({(h.grid ?? '?').slice(0, 4)})</span>
|
|
<span className="text-success w-14">{snr(h.snr)} dB</span>
|
|
{h.offset_hz > 0 && h.offset_hz < 10000 && (
|
|
<span className="ml-auto text-muted-foreground whitespace-nowrap">@ +{h.offset_hz} Hz</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-[11px] text-muted-foreground italic">{t('psk.fromAreaEmpty')}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── His passband ────────────────────────────────────────── */}
|
|
<div>
|
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('psk.passband')}</span>
|
|
<span className="text-[10px] font-mono text-muted-foreground">
|
|
{(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')}
|
|
</span>
|
|
</div>
|
|
<div className="relative flex items-end gap-px h-10 rounded bg-muted/40 px-1 py-0.5 overflow-hidden">
|
|
{columns.map((c) => {
|
|
const ratio = c.count / maxCount;
|
|
return (
|
|
<div key={c.edge}
|
|
className={cn('flex-1 min-w-0 rounded-sm',
|
|
c.count === 0 ? 'bg-border'
|
|
: ratio > 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 && (
|
|
<div className="absolute top-0 bottom-0 w-0.5 bg-success pointer-events-none"
|
|
style={{ left: `${((a!.suggested_offset - LO) / (HI - LO)) * 100}%`, boxShadow: '0 0 4px currentColor' }}
|
|
title={t('psk.tryOffset', { hz: a!.suggested_offset })} />
|
|
)}
|
|
</div>
|
|
<div className="relative h-3 mt-0.5 text-[9px] font-mono text-muted-foreground">
|
|
{[1000, 2000, 3000, 4000].map((hz) => (
|
|
<span key={hz} className="absolute whitespace-nowrap"
|
|
style={{ left: `${((hz - LO) / (HI - LO)) * 100}%`, transform: `translateX(${hz === HI ? '-100%' : '-50%'})` }}>
|
|
{hz}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{(a?.suggested_offset ?? 0) > 0 && (
|
|
<div className="text-center text-[11px] font-mono text-success mt-0.5">
|
|
🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|