Decodes: - one click SELECTS, two transmit. A single click handed the decode straight to WSJT-X as a reply, so brushing a row while reading the band started calling a station. - the list empties for a receiver that changes band, and a receiver column appears when more than one is feeding one merged list. - the period clock turns red while transmitting: it is the one thing on the screen that moves, so it is where the eye already is. - a WL badge, after the LoTW "L" — one letter, always in the same place, so the column does not shift from row to row. - the auto-call switch, its target and its count, and the chase list: naming the station you are waiting for is done while watching the band, not in a settings tree. Themes: DXHunter's slate with its own blue, and the same slate with OpsLog's orange. Counted across its sources rather than guessed from one panel — blue is 132 uses to violet's 25, and the violet is the PSK Reporter panel alone. Watchlist: drawn as DXHunter draws it — the callsign in the interface font rather than monospaced, which is the difference that shows with the two windows side by side. FT Map: arcs no longer run off the side of the map. The map shows one world, and a path crossing the antimeridian was drawn past 180° into the blank space beside it — from VK that is most of them. Cluster: "superfox", "fox/hound" and "F/H" in a comment are read as FT8. They are WSJT-X's DXpedition transmit modes, and the comment fell through to the band plan and came out DATA — which then decided the band+mode verdict. A decoder is named by what it IS: Nexus sends its packets as "Tempo", the engine inside it, and OpsLog showed a program nobody has heard of.
255 lines
11 KiB
TypeScript
255 lines
11 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 { formatDistance } from '@/lib/units';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { chaseAllows } from '@/lib/spotDisplay';
|
|
import { markerColour } from '@/lib/spotMarkers';
|
|
import { cn } from '@/lib/utils';
|
|
import { GetChaseNewSpots, GetPSKReporterStatus } 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.
|
|
//
|
|
// A category the operator does not chase is not a category here either: with
|
|
// prefixes and squares switched off this panel showed rows whose only reason
|
|
// for being listed had been withdrawn everywhere else.
|
|
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 && chaseAllows('pfx')) return 'pfx';
|
|
if (s.new_grid && chaseAllows('grid')) return 'grid';
|
|
return null;
|
|
}
|
|
|
|
const FILTER_KEY = 'opslog.chaseNewFilters';
|
|
|
|
function allowedCategories(): Category[] {
|
|
return CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key);
|
|
}
|
|
|
|
function loadFilters(): Set<Category> {
|
|
const allowed = allowedCategories();
|
|
try {
|
|
const raw = localStorage.getItem(FILTER_KEY);
|
|
if (raw) {
|
|
const list = JSON.parse(raw) as Category[];
|
|
// A stored set holding NONE of the categories on offer hides the whole
|
|
// panel, for ever, with nothing to say why — and that is exactly what a
|
|
// preference written by an older build does once a category is renamed.
|
|
// Treated as "no preference": a panel that shows nothing at every launch
|
|
// is never what was meant, and the chips are one click away.
|
|
if (Array.isArray(list) && list.some((k) => allowed.includes(k))) return new Set(list);
|
|
}
|
|
} catch { /* a corrupt preference is not worth a broken panel */ }
|
|
return new Set(allowed);
|
|
}
|
|
|
|
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);
|
|
// The FEED, not the list: connected or not, how many reports it has taken,
|
|
// and what it is filtered on. Without it an empty panel says nothing about
|
|
// whether anything is arriving at all — which is the first question.
|
|
const [feed, setFeed] = useState<any>(null);
|
|
|
|
// 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); }
|
|
const st = await GetPSKReporterStatus();
|
|
if (alive) setFeed(st);
|
|
} 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.filter((c) => chaseAllows(c.key)).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>
|
|
|
|
{/* Both numbers: what is on screen, and what was heard. They differ
|
|
exactly when a category is switched off, which is the one case an
|
|
operator reads this panel as broken. */}
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
|
{loaded ? (shown.length === spots.length
|
|
? t('chn.count', { n: spots.length })
|
|
: t('chn.countOf', { n: shown.length, total: spots.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 ? formatDistance(s.dist_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>
|
|
|
|
{/* The radius comes from the FEED, not from a sentence: it was written
|
|
into this line as "~300 km" and stayed 300 while the setting said
|
|
1000, which is the panel telling the operator their change did not
|
|
take when it had. */}
|
|
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">
|
|
{t('chn.heardWithin', { km: feed?.near_km || 300 })}
|
|
{feed && (feed.running
|
|
? <span className="text-success"> · {t('chn.feedOn', { n: feed.received ?? 0, sq: feed.squares ?? 0 })}</span>
|
|
: <span className="text-warning"> · {feed.last_err ? t('chn.feedErr', { e: feed.last_err }) : t('chn.feedOff')}</span>)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|