// ChaseNewPanel — the stations PSK Reporter is hearing NEAR HERE that are new // against the log. // // A DX cluster tells you what somebody chose to spot. This tells you what is // actually being decoded in your own region, which is a different and often // larger set: nobody spots the FT8 caller running ten watts from a rare square. // // The one thing an operator has to know, and the reason for the line at the // bottom: PSK Reporter carries DIGITAL MODES ONLY. An empty panel means nothing // new is being decoded on FT8/FT4/JS8 near here — not that the band is dead. import { useEffect, useMemo, useState } from 'react'; import { Radar, Loader2, X } from 'lucide-react'; import { useI18n } from '@/lib/i18n'; import { markerColour } from '@/lib/spotMarkers'; import { cn } from '@/lib/utils'; import { GetChaseNewSpots } from '../../wailsjs/go/main/App'; export interface ChaseNewSpot { call: string; band: string; mode: string; freq_hz?: number; grid?: string; country?: string; cont?: string; dist_km?: number; bearing?: number; status?: string; new_pfx?: boolean; new_grid?: boolean; lotw?: boolean; at: string; } interface Props { // Tuning the rig to a row is the whole point — a station heard on 14.074 is // only useful if you can get there in one click. onPick?: (s: ChaseNewSpot) => void; onClose?: () => void; } // The categories, in priority order. A station can be several at once — a new // band AND a new prefix — but a row shows ONE, the first that matches here. // // The order is by what would make an operator leave what they are doing: a new // entity beats a new band on it, which beats a new mode, which beats a new slot; // a prefix or a square is worth knowing but never worth interrupting a QSO for. // Two badges on one line also made the list unreadable at a glance, which is the // only thing this panel is for. type Category = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid'; const CATEGORIES: Array<{ key: Category; labelKey: string; colour: string }> = [ { key: 'dxcc', labelKey: 'clg2.newDxcc', colour: 'var(--danger)' }, { key: 'band', labelKey: 'clg2.newBand', colour: 'var(--danger)' }, { key: 'mode', labelKey: 'clg2.newMode', colour: 'var(--danger)' }, { key: 'slot', labelKey: 'clg2.newSlot', colour: 'var(--danger)' }, { key: 'pfx', labelKey: 'clg2.newPfx', colour: markerColour('new_pfx') }, { key: 'grid', labelKey: 'clg2.newGrid', colour: markerColour('new_grid') }, ]; // categoryOf is the single thing a row says about a station. function categoryOf(s: ChaseNewSpot): Category | null { switch (s.status) { case 'new': return 'dxcc'; case 'new-band-mode': return 'band'; case 'new-band': return 'band'; case 'new-mode': return 'mode'; case 'new-slot': return 'slot'; } if (s.new_pfx) return 'pfx'; if (s.new_grid) return 'grid'; return null; } const FILTER_KEY = 'opslog.chaseNewFilters'; function loadFilters(): Set { try { const raw = localStorage.getItem(FILTER_KEY); if (raw) { const list = JSON.parse(raw) as Category[]; if (Array.isArray(list)) return new Set(list); } } catch { /* a corrupt preference is not worth a broken panel */ } return new Set(CATEGORIES.map((c) => c.key)); } export function ChaseNewPanel({ onPick, onClose }: Props) { const { t } = useI18n(); const [spots, setSpots] = useState([]); const [loaded, setLoaded] = useState(false); const [on, setOn] = useState>(loadFilters); // Polled rather than pushed: the feed can deliver several a second under an // opening, and an event per row would be a redraw per row for a list nobody // reads that fast. useEffect(() => { let alive = true; const tick = async () => { try { const r = ((await GetChaseNewSpots()) ?? []) as ChaseNewSpot[]; if (alive) { setSpots(r); setLoaded(true); } } catch { /* the feed may not be up yet */ } }; tick(); const id = window.setInterval(tick, 5000); return () => { alive = false; window.clearInterval(id); }; }, []); function toggle(k: Category) { setOn((prev) => { const next = new Set(prev); if (next.has(k)) next.delete(k); else next.add(k); try { localStorage.setItem(FILTER_KEY, JSON.stringify([...next])); } catch { /* not worth failing over */ } return next; }); } const shown = useMemo(() => { return spots.filter((s) => { const c = categoryOf(s); return c !== null && on.has(c); }); }, [spots, on]); return (
{t('chn.title')} {/* Filters, in the same order and colours as the badges they hide. */}
{CATEGORIES.map((c) => ( ))}
{loaded ? t('chn.count', { n: shown.length }) : ''} {onClose && ( )}
{!loaded ? (
{t('chn.loading')}
) : shown.length === 0 ? (

{spots.length === 0 ? t('chn.empty') : t('chn.allFiltered')}

) : (
{shown.map((s, i) => { const c = categoryOf(s); const def = CATEGORIES.find((x) => x.key === c); return ( ); })}
)}

{t('chn.digitalOnly')}

); }