219 lines
8.6 KiB
TypeScript
219 lines
8.6 KiB
TypeScript
// 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<Category> {
|
|
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<ChaseNewSpot[]>([]);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [on, setOn] = useState<Set<Category>>(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 (
|
|
<div className="flex h-full flex-col rounded-md border border-border bg-card overflow-hidden">
|
|
<div className="flex items-center gap-2 border-b border-border px-2 py-1.5">
|
|
<Radar className="size-3.5 shrink-0 text-primary" />
|
|
<span className="shrink-0 text-xs font-semibold">{t('chn.title')}</span>
|
|
|
|
{/* Filters, in the same order and colours as the badges they hide. */}
|
|
<div className="flex flex-1 flex-wrap items-center gap-1">
|
|
{CATEGORIES.map((c) => (
|
|
<button
|
|
key={c.key}
|
|
type="button"
|
|
onClick={() => toggle(c.key)}
|
|
title={t('chn.filterHint')}
|
|
className={cn(
|
|
'rounded border px-1 py-px text-[9px] font-bold uppercase tracking-wide transition-colors',
|
|
on.has(c.key) ? 'border-transparent' : 'border-border text-muted-foreground opacity-50',
|
|
)}
|
|
style={on.has(c.key) ? { color: c.colour, borderColor: c.colour } : undefined}
|
|
>
|
|
{t(c.labelKey).replace(/^(NEW|NOUV)\s+/i, '')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
|
{loaded ? t('chn.count', { n: shown.length }) : ''}
|
|
</span>
|
|
{onClose && (
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
title={t('chn.close')}
|
|
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{!loaded ? (
|
|
<div className="flex items-center justify-center gap-2 p-3 text-[11px] text-muted-foreground">
|
|
<Loader2 className="size-3 animate-spin" /> {t('chn.loading')}
|
|
</div>
|
|
) : shown.length === 0 ? (
|
|
<p className="p-3 text-[11px] text-muted-foreground leading-relaxed">
|
|
{spots.length === 0 ? t('chn.empty') : t('chn.allFiltered')}
|
|
</p>
|
|
) : (
|
|
<div className="divide-y divide-border/60">
|
|
{shown.map((s, i) => {
|
|
const c = categoryOf(s);
|
|
const def = CATEGORIES.find((x) => x.key === c);
|
|
return (
|
|
<button
|
|
key={`${s.call}-${s.band}-${s.mode}-${i}`}
|
|
type="button"
|
|
onClick={() => onPick?.(s)}
|
|
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[11px] hover:bg-accent/40"
|
|
title={[
|
|
s.country,
|
|
s.grid,
|
|
s.dist_km ? `${s.dist_km} km` : '',
|
|
s.freq_hz ? `${(s.freq_hz / 1000).toFixed(1)} kHz` : '',
|
|
].filter(Boolean).join(' · ')}
|
|
>
|
|
<span className="w-[84px] shrink-0 truncate font-mono font-bold">{s.call}</span>
|
|
<span className="w-9 shrink-0 font-mono text-muted-foreground">{s.band}</span>
|
|
<span className="w-10 shrink-0 truncate text-muted-foreground">{s.mode}</span>
|
|
{/* No frequency column: clicking the row tunes the rig to it,
|
|
so the number was a thing to read and never to use. It is
|
|
still carried on the spot — the click is what needs it —
|
|
and shows in the row's tooltip for anyone who wants it.
|
|
The country takes the space it freed. */}
|
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">{s.country ?? ''}</span>
|
|
{def && (
|
|
<span className="shrink-0 text-[9px] font-bold" style={{ color: def.colour }}>
|
|
{t(def.labelKey)}
|
|
</span>
|
|
)}
|
|
{s.lotw && <span className="shrink-0 text-[9px] text-info" title="LoTW">L</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
|
|
</div>
|
|
);
|
|
}
|