feat(ui): the FT decodes panel, two DXHunter themes, and the maps

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.
This commit is contained in:
2026-09-05 21:46:06 +02:00
parent e4a5d42b85
commit 8dbc4b7e62
7 changed files with 321 additions and 41 deletions
+65 -5
View File
@@ -60,7 +60,7 @@ import {
GetFolderSync, SaveFolderSync, PickFolderSyncFolder, GetFolderSyncStatus, SyncFolderNow,
GetRelayAuto, SaveRelayAuto, GetStationDevices,
GetAwardDefs, GetTrackedAwards, SaveTrackedAwards,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, GetChaseNewBands, SetChaseNewBands, GetWatchlistContestCalls, SetWatchlistContestCalls, GetWatchlistContestPattern, SetWatchlistContestPattern, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -405,16 +405,21 @@ interface TreeProps {
flexAvailable?: boolean;
}
function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
// The sidebar. Memoised and its tree built once per (language, radio): it is
// sixty-odd items that depend on nothing an operator types, and it was rebuilt
// and re-rendered on every keystroke in every field of every panel — the whole
// dialog holds its state in one component, so one character redraws all of it.
const Tree = memo(function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
const { t } = useI18n();
const nodes = useMemo(() => buildTree(!!flexAvailable, t), [flexAvailable, t]);
return (
<nav className="text-sm">
{buildTree(!!flexAvailable, t).map((node, i) => (
{nodes.map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))}
</nav>
);
}
});
function TreeNodeView({
node, depth, selected, onSelect,
@@ -472,6 +477,11 @@ function TreeNodeView({
// identity is STABLE across SettingsModal re-renders; defining them inside the
// component would give each render a fresh function, remounting the Radix
// Select and slamming the open dropdown shut on any ambient re-render.
// The bands the Chase new filter offers, in band-plan order. Mirrors
// ChaseNewBands in pskchase.go, which is what actually filters the feed —
// a band added there and not here is simply one nobody can switch off.
const CHASE_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
@@ -2089,11 +2099,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
const [chaseNew, setChaseNew] = useState(false);
const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' });
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2 });
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2, on_screen_only: true });
// The named contest callsigns. Raw text in state, written on blur: it is a
// multi-line list, and normalising it on every keystroke would fight the
// Return key — the one key this box is built around.
const [contestCalls, setContestCalls] = useState('');
// Which bands the Chase new panel shows. The station's own band list still
// applies underneath — this one is "what am I watching tonight".
const [chaseBands, setChaseBands] = useState<string[]>([]);
const saveChaseBands = (next: string[]) => {
setChaseBands(next);
void SetChaseNewBands(next);
};
const [contestPattern, setContestPattern] = useState('');
const saveAC = async (next: any) => {
setAc(next);
@@ -2132,6 +2149,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ }
try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ }
try { setChaseBands((await GetChaseNewBands()) ?? []); } catch { /* defaults stand */ }
try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ }
try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
@@ -5402,6 +5420,34 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
<span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
</div>
)}
{/* Which bands the panel lists. Not the station's band list that
one says what this station can work at all and still applies
underneath. This says what is worth watching tonight, which is
a different question and changes far more often. */}
{chaseNew && (
<div className="pl-6 space-y-1">
<span className="text-xs text-muted-foreground">{t('chn.bands')}</span>
<div className="flex flex-wrap items-center gap-1">
{CHASE_BANDS.map((b) => {
const on = chaseBands.includes(b);
return (
<button key={b} type="button"
onClick={() => saveChaseBands(on ? chaseBands.filter((x) => x !== b) : [...chaseBands, b])}
className={cn('h-6 px-2 rounded-full border text-[11px] font-medium transition-colors',
on ? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-muted-foreground hover:bg-muted')}>
{b}
</button>
);
})}
<button type="button" onClick={() => saveChaseBands([...CHASE_BANDS])}
className="h-6 px-2 rounded-full border border-border text-[11px] text-muted-foreground hover:bg-muted">
{t('chn.bandsAll')}
</button>
</div>
<p className="text-xs text-muted-foreground">{t('chn.bandsHint')}</p>
</div>
)}
</div>
{/* PSK Reporter analysis of the station being called. Same service as
@@ -5505,6 +5551,20 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</span>
))}
</div>
{/* A rule about what the transmitter may call, stated once. The
decodes panel has a LoTW chip too, but that one is a way of
READING the band it is flicked on and off while looking
around, and the panel can be closed. */}
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={ac.on_screen_only !== false}
onCheckedChange={(c) => saveAC({ ...ac, on_screen_only: !!c })} />
<span>{t('ac.onScreen')} <span className="text-muted-foreground">{t('ac.onScreenHint')}</span></span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!ac.trace}
onCheckedChange={(c) => saveAC({ ...ac, trace: !!c })} />
<span>{t('ac.trace')} <span className="text-muted-foreground">{t('ac.traceHint')}</span></span>
</label>
</div>
)}
</div>