feat(cluster): Chase New — a panel for what is new and audible here
PSK Reporter tells you what is actually being decoded in your region, which is a larger set than what somebody chose to spot: nobody spots the FT8 caller running ten watts from a rare square. Almost all of it existed. The MQTT payload already carries frequency, mode, transmitter and both grids; the watcher already drops any report collected further than NearKm from the operator, which is exactly the question worth asking — the station is being heard HERE, not in Japan; and with grid chasing on the subscription is already every band, filtered at the broker by receiver square, measured at 0.2 to 1.2 messages a second. This reads messages that were arriving and being discarded. "New" is not decided here. Every spot goes through ClusterSpotStatuses, the same function the DX cluster grid uses and the same cached index, so the two panels cannot drift apart the way the county columns did. Cost per message is map lookups behind an option cached in an atomic, because the MQTT goroutine must never wait on the settings store. The option is its own, not nested under grid chasing: chasing squares and chasing entities are different wants, and the feed now has three consumers, any one of which brings it up and none of which cuts the others loose when it goes down. The panel says "digital modes only" in its footer. An empty list has to mean "nothing new on FT8/FT4/JS8 near you", not "the band is dead" — it will never show a new entity on CW.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
// 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 10 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, useState } from 'react';
|
||||
import { Radar, Loader2 } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { markerColour } from '@/lib/spotMarkers';
|
||||
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;
|
||||
}
|
||||
|
||||
// statusLabel maps the backend's vocabulary — the cluster's own — to a short
|
||||
// badge. Kept to the same words the DX cluster list uses: an operator should not
|
||||
// have to learn two names for one idea.
|
||||
function statusKey(s: ChaseNewSpot): string | null {
|
||||
switch (s.status) {
|
||||
case 'new': return 'clg2.newDxcc';
|
||||
case 'new-band': return 'clg2.newBand';
|
||||
case 'new-mode': return 'clg2.newMode';
|
||||
case 'new-slot': return 'clg2.newSlot';
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ChaseNewPanel({ onPick }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [spots, setSpots] = useState<ChaseNewSpot[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// 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); };
|
||||
}, []);
|
||||
|
||||
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 text-primary" />
|
||||
<span className="text-xs font-semibold">{t('chn.title')}</span>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
{spots.length > 0 ? t('chn.count', { n: spots.length }) : ''}
|
||||
</span>
|
||||
</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>
|
||||
) : spots.length === 0 ? (
|
||||
<p className="p-3 text-[11px] text-muted-foreground leading-relaxed">{t('chn.empty')}</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{spots.map((s, i) => {
|
||||
const sk = statusKey(s);
|
||||
return (
|
||||
<button
|
||||
key={`${s.call}-${s.band}-${s.mode}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onPick?.(s)}
|
||||
className="flex w-full items-center gap-2 px-2 py-1 text-left text-[11px] hover:bg-accent/40"
|
||||
title={[s.country, s.grid, s.dist_km ? `${s.dist_km} km` : ''].filter(Boolean).join(' · ')}
|
||||
>
|
||||
<span className="font-mono font-bold w-24 truncate">{s.call}</span>
|
||||
<span className="font-mono text-muted-foreground w-12">{s.band}</span>
|
||||
<span className="text-muted-foreground w-10 truncate">{s.mode}</span>
|
||||
<span className="font-mono text-muted-foreground w-16 text-right">
|
||||
{s.freq_hz ? (s.freq_hz / 1000).toFixed(1) : '—'}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">{s.country ?? ''}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{sk && <span className="font-bold" style={{ color: 'var(--danger)' }}>{t(sk)}</span>}
|
||||
{s.new_pfx && <span style={{ color: markerColour('new_pfx') }}>{t('clg2.newPfx')}</span>}
|
||||
{s.new_grid && <span style={{ color: markerColour('new_grid') }}>{t('clg2.newGrid')}</span>}
|
||||
{s.lotw && <span className="text-[9px] text-info" title="LoTW">L</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
GetADIFMonitor, SaveADIFMonitor, PickADIFMonitorFile,
|
||||
GetRelayAuto, SaveRelayAuto, GetStationDevices,
|
||||
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||
@@ -1562,6 +1562,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// feed up or down — so the write has to go where those live.
|
||||
const [bandOpen, setBandOpen] = useState<any>({ enabled: false, bands: [], available: [] });
|
||||
const [chaseGrids, setChaseGrids] = useState(false);
|
||||
const [chaseNew, setChaseNew] = useState(false);
|
||||
const [spotTTL, setSpotTTL] = useState(0);
|
||||
const [spotTTLText, setSpotTTLText] = useState('0');
|
||||
const [gridStat, setGridStat] = useState<any>(null);
|
||||
@@ -1574,6 +1575,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
(async () => {
|
||||
try { setBandOpen(await GetBandOpenSettings()); } catch { /* defaults stand */ }
|
||||
try { setChaseGrids(await GetChaseNewGrids()); } catch { /* defaults stand */ }
|
||||
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
|
||||
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
||||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||
})();
|
||||
@@ -4339,6 +4341,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chase new — its own option, NOT nested under grid chasing. Chasing
|
||||
squares and chasing entities are different wants; they only share
|
||||
the PSK Reporter feed, which either one brings up. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={chaseNew} className="mt-0.5"
|
||||
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
|
||||
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Band-opening watch. It lives HERE, with the cluster nodes, because
|
||||
switching it on adds two of them — the operator should see that
|
||||
happen where it happens rather than find nodes they did not add. */}
|
||||
|
||||
Reference in New Issue
Block a user