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:
+55
-6
@@ -54,7 +54,7 @@ import {
|
||||
GetFlexState, FlexAmpOperate,
|
||||
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
|
||||
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
||||
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, ResetAutoCall,
|
||||
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, HaltAutoCall, WatchlistEntries,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||
@@ -123,6 +123,7 @@ import { GridSquareMap } from '@/components/GridSquareMap';
|
||||
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
|
||||
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
|
||||
import { PSKReporterPanel } from '@/components/PSKReporterPanel';
|
||||
import { decoderName } from '@/lib/decoderName';
|
||||
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||
@@ -2406,6 +2407,17 @@ export default function App() {
|
||||
// the watchlist panel: a call can be added from the cluster or the
|
||||
// DXpeditions tab, and the old inline message lived on a page nobody was
|
||||
// looking at.
|
||||
// The watch list as patterns, for the decodes badge. Loaded once and kept in
|
||||
// step with the same event the notice below listens to — a call added from
|
||||
// the cluster has to light up in the decodes list too.
|
||||
const [watchPatterns, setWatchPatterns] = useState<string[]>([]);
|
||||
const loadWatchPatterns = useCallback(() => {
|
||||
WatchlistEntries()
|
||||
.then((es: any[]) => setWatchPatterns((es ?? []).map((e: any) => String(e?.callsign ?? '')).filter(Boolean)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { loadWatchPatterns(); }, [loadWatchPatterns]);
|
||||
useEffect(() => EventsOn('watchlist:changed', () => loadWatchPatterns()), [loadWatchPatterns]);
|
||||
const [wlNotice, setWlNotice] = useState<{ call: string; added: boolean } | null>(null);
|
||||
const wlNoticeTimer = useRef<number | undefined>(undefined);
|
||||
useEffect(() => EventsOn('watchlist:changed', (e: any) => {
|
||||
@@ -2597,6 +2609,9 @@ export default function App() {
|
||||
// Staged like the cluster's, so a period arriving as one burst of fifty
|
||||
// packets costs one status lookup and one render, not fifty of each.
|
||||
const pendingDecodesRef = useRef<DecodeRow[]>([]);
|
||||
// The band each receiver was last decoding on. A band change empties that
|
||||
// receiver's list — see the flush below.
|
||||
const decoderBandRef = useRef<Map<string, string>>(new Map());
|
||||
const pendingDecodeTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
|
||||
@@ -3718,9 +3733,42 @@ export default function App() {
|
||||
});
|
||||
}
|
||||
} catch { /* status unresolved — the decode still shows, just unflagged */ }
|
||||
// A BAND CHANGE empties that receiver's list.
|
||||
//
|
||||
// What is on 20 m says nothing about 40 m, and half a screen of stations
|
||||
// that are no longer reachable is worse than an empty one: the period
|
||||
// headings still march on, the rows still carry status badges, and the
|
||||
// operator reads a band they have left. Per receiver — in a split view
|
||||
// the other one has not moved — and only when the new band is known.
|
||||
const moved = new Map<string, string>();
|
||||
for (const d of batch) {
|
||||
const inst = d.instance ?? '';
|
||||
const band = (d.band ?? '').toLowerCase();
|
||||
if (!band) continue;
|
||||
const was = decoderBandRef.current.get(inst);
|
||||
decoderBandRef.current.set(inst, band);
|
||||
if (was && was !== band) moved.set(inst, band);
|
||||
}
|
||||
if (moved.size > 0) {
|
||||
for (const [inst, band] of moved) {
|
||||
LogUIError('decodes', `${decoderName(inst) || 'decoder'} moved to ${band.toUpperCase()} — its earlier decodes cleared`, '');
|
||||
}
|
||||
setTxMsgs((arr) => arr.filter((m) => !moved.has(m.instance ?? '')));
|
||||
}
|
||||
setDecodes((arr) => {
|
||||
const cutoff = Date.now() - DECODE_KEEP_MS;
|
||||
const next = [...arr, ...batch].filter((d) => Date.parse(d.at) >= cutoff);
|
||||
// Rows from a receiver that has just changed band go with it — the
|
||||
// batch itself is already on the new band.
|
||||
const kept = moved.size === 0 ? arr : arr.filter((d) => !moved.has(d.instance ?? ''));
|
||||
// The batch can straddle the change — a 300 ms flush can hold the last
|
||||
// decodes of the old band and the first of the new. Only the new band
|
||||
// survives for a receiver that moved.
|
||||
const fresh = moved.size === 0 ? batch
|
||||
: batch.filter((d) => {
|
||||
const b2 = moved.get(d.instance ?? '');
|
||||
return !b2 || (d.band ?? '').toLowerCase() === b2;
|
||||
});
|
||||
const next = [...kept, ...fresh].filter((d) => Date.parse(d.at) >= cutoff);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -6385,15 +6433,16 @@ export default function App() {
|
||||
// An empty instance lets the backend fall back to whichever application
|
||||
// last reported its status — the normal single-receiver case.
|
||||
onHalt={(instance) => {
|
||||
// Halt means stop, including whatever auto-call had started — and it
|
||||
// clears the engine's state, so a target it had given up on is not
|
||||
// still sitting there when the operator switches it back on.
|
||||
ResetAutoCall().catch(() => {});
|
||||
// Halt is a verdict on the station being called: it is set aside for
|
||||
// the session rather than released, or the next period would call it
|
||||
// straight back — which is exactly what the operator just stopped.
|
||||
HaltAutoCall().catch(() => {});
|
||||
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
}}
|
||||
autoCallOn={!!autoCallStatus?.enabled}
|
||||
onToggleAutoCall={toggleAutoCall}
|
||||
autoCall={autoCallStatus}
|
||||
watchlist={watchPatterns}
|
||||
autoCallOnly={autoCallStatus?.only ?? ''}
|
||||
onSetAutoCallOnly={(list) => {
|
||||
setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { chaseAllows } from '@/lib/spotDisplay';
|
||||
import { markerColour } from '@/lib/spotMarkers';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GetChaseNewSpots } from '../../wailsjs/go/main/App';
|
||||
import { GetChaseNewSpots, GetPSKReporterStatus } from '../../wailsjs/go/main/App';
|
||||
|
||||
export interface ChaseNewSpot {
|
||||
call: string;
|
||||
@@ -80,15 +80,25 @@ function categoryOf(s: ChaseNewSpot): Category | 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[];
|
||||
if (Array.isArray(list)) return new Set(list);
|
||||
// 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(CATEGORIES.filter((c) => chaseAllows(c.key)).map((c) => c.key));
|
||||
return new Set(allowed);
|
||||
}
|
||||
|
||||
export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
@@ -96,6 +106,10 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
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
|
||||
@@ -106,6 +120,8 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
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();
|
||||
@@ -154,8 +170,13 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
))}
|
||||
</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 ? t('chn.count', { n: shown.length }) : ''}
|
||||
{loaded ? (shown.length === spots.length
|
||||
? t('chn.count', { n: spots.length })
|
||||
: t('chn.countOf', { n: shown.length, total: spots.length })) : ''}
|
||||
</span>
|
||||
{onClose && (
|
||||
<button
|
||||
@@ -218,7 +239,16 @@ export function ChaseNewPanel({ onPick, onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="border-t border-border px-2 py-1 text-[10px] text-muted-foreground">{t('chn.digitalOnly')}</p>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { chaseAllows } from '@/lib/spotDisplay';
|
||||
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
|
||||
import { decoderName } from '@/lib/decoderName';
|
||||
|
||||
export type Decode = {
|
||||
@@ -125,6 +126,9 @@ interface Props {
|
||||
// waiting for is done WHILE watching the band, not in a settings tree.
|
||||
autoCallOnly?: string;
|
||||
onSetAutoCallOnly?: (list: string) => void;
|
||||
// The watch list, as PATTERNS (VK9*, 3Y0J). A decode of one is worth saying
|
||||
// so where the operator is reading the band, not only in the watchlist tab.
|
||||
watchlist?: string[];
|
||||
}
|
||||
|
||||
// The "new" categories, as toggle badges — the same idea and the same colours as
|
||||
@@ -171,6 +175,21 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// isWatched applies the watch list's own rule — a trailing "*" is a prefix,
|
||||
// anything else is the whole callsign — so a decode is judged here exactly as
|
||||
// the backend judges a spot. Two rules for one list is how a badge and an alert
|
||||
// start disagreeing about the same station.
|
||||
function isWatched(call: string, patterns: string[] | undefined): boolean {
|
||||
if (!patterns || patterns.length === 0 || !call) return false;
|
||||
const c = call.toUpperCase();
|
||||
for (const raw of patterns) {
|
||||
const p = (raw ?? '').toUpperCase().trim();
|
||||
if (!p) continue;
|
||||
if (p.endsWith('*') ? c.startsWith(p.slice(0, -1)) : c === p) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const CAT_KEY = 'opslog.decodeCats';
|
||||
const SPLIT_KEY = 'opslog.decodeSplit';
|
||||
const FILTER_KEY = 'opslog.decodeFilters';
|
||||
@@ -252,9 +271,14 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
|
||||
// One declaration per column, in display order: the header, the widths and the
|
||||
// resize handles all read from this, so a column cannot be resized in the header
|
||||
// and stay the old width in the body.
|
||||
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
|
||||
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
|
||||
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
|
||||
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
||||
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and
|
||||
// that is the case it exists for: two decoders on one band send the same
|
||||
// stations twice, each with its own SNR and DT, and a merged list gave no way
|
||||
// at all to tell a second receiver from a duplicate.
|
||||
{ key: 'rx', tkey: 'dec.colRx', def: 74, min: 44 },
|
||||
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
|
||||
{ key: 'dt', tkey: 'dec.colDt', def: 44, min: 32 },
|
||||
{ key: 'freq', tkey: 'dec.colFreq', def: 56, min: 40 },
|
||||
@@ -450,7 +474,7 @@ function renderMsg(msg: string, me: string, calling: string) {
|
||||
//
|
||||
// It is the one moving thing on the panel, and it answers the question an
|
||||
// operator actually has between overs: how long until the next batch.
|
||||
function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
|
||||
function PeriodClock({ trSec, mode, tx }: { trSec: number; mode?: string; tx?: boolean }) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
// 100 ms: smooth enough for a bar that fills in three and three quarter
|
||||
@@ -466,19 +490,26 @@ function PeriodClock({ trSec, mode }: { trSec: number; mode?: string }) {
|
||||
// The last fifth of a slot is when a decode is imminent and an operator
|
||||
// deciding whether to answer has run out of time to think.
|
||||
const closing = left <= trSec / 5;
|
||||
// TRANSMITTING outranks both. The bar is the one thing on this screen that
|
||||
// moves continuously, so it is what the eye is already on — and "am I on the
|
||||
// air" is the state worth reading from across the room. Red, and it stays red
|
||||
// for the whole over rather than turning amber near the end of it.
|
||||
const tone = tx ? 'danger' : closing ? 'warning' : '';
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2 shrink-0" title={mode ? `${mode} · ${trSec}s` : `${trSec}s`}>
|
||||
<Timer className={cn('size-4', closing ? 'text-warning' : 'text-muted-foreground')} />
|
||||
<Timer className={cn('size-4',
|
||||
tone === 'danger' ? 'text-danger' : tone === 'warning' ? 'text-warning' : 'text-muted-foreground')} />
|
||||
<span className="relative h-1.5 w-24 rounded-full bg-muted overflow-hidden">
|
||||
<span
|
||||
className={cn('absolute inset-y-0 left-0 rounded-full transition-[width] duration-100 ease-linear',
|
||||
closing ? 'bg-warning' : 'bg-primary')}
|
||||
tone === 'danger' ? 'bg-danger' : tone === 'warning' ? 'bg-warning' : 'bg-primary')}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className={cn('font-mono text-sm tabular-nums w-10 text-right',
|
||||
closing ? 'text-warning font-semibold' : 'text-muted-foreground')}>
|
||||
tone === 'danger' ? 'text-danger font-semibold'
|
||||
: tone === 'warning' ? 'text-warning font-semibold' : 'text-muted-foreground')}>
|
||||
{left.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -552,7 +583,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: Props) {
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
|
||||
const { t } = useI18n();
|
||||
// Column widths, dragged in the header and shared by every row. Persisted
|
||||
// through writeUiPref (not raw localStorage) so the layout travels with data/
|
||||
@@ -567,8 +598,6 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
if (el && el.tagName === 'INPUT' && el.getAttribute('placeholder') === t('dec.chasePh')) return;
|
||||
setOnlyText(autoCallOnly ?? '');
|
||||
}, [autoCallOnly, t]);
|
||||
const template = useMemo(() => COLS.map((c) => `${colw[c.key]}px`).join(' '), [colw]);
|
||||
const tableW = useMemo(() => COLS.reduce((s, c) => s + colw[c.key], 0), [colw]);
|
||||
const setColWidth = (key: ColKey, px: number) => {
|
||||
const col = COLS.find((c) => c.key === key)!;
|
||||
const w = Math.min(COL_MAX, Math.max(col.min, Math.round(px)));
|
||||
@@ -681,6 +710,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [decodes, spotStatus]);
|
||||
|
||||
// The receiver column is dead weight with one decoder — which is nearly
|
||||
// everybody — so it is not there at all until a second one starts feeding.
|
||||
const cols = useMemo(() => COLS.filter((c) => c.key !== 'rx' || (instances.length > 1 && !splitByInstance)),
|
||||
[instances.length, splitByInstance]);
|
||||
const template = useMemo(() => cols.map((c) => `${colw[c.key]}px`).join(' '), [cols, colw]);
|
||||
const tableW = useMemo(() => cols.reduce((s, c) => s + colw[c.key], 0), [cols, colw]);
|
||||
// All seven, always, in their usual order.
|
||||
//
|
||||
// The chips used to be built from the continents ON the feed, which read as a
|
||||
@@ -714,6 +749,22 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [decodes, spotStatus, cqOnly, lotwOnly, cats, bandSel, modeSel, contSel, minSnr, search]);
|
||||
// What this panel is SHOWING, published to the auto-call engine.
|
||||
//
|
||||
// The filters are the operator's control over the transmitter as well as
|
||||
// over the list: a station filtered off the screen is not called. The panel
|
||||
// sends the callsigns rather than the filter settings, so there is one
|
||||
// definition of "shown" and not two — the engine cannot disagree with what
|
||||
// is in front of the operator.
|
||||
useEffect(() => {
|
||||
const calls = [...new Set(filtered.map((d) => (d.call ?? '').toUpperCase()).filter(Boolean))];
|
||||
SetAutoCallVisible(calls, true).catch(() => {});
|
||||
}, [filtered]);
|
||||
// Closed, it publishes nothing: filters that are not on the screen cannot
|
||||
// silence the engine behind the operator's back.
|
||||
useEffect(() => () => { SetAutoCallVisible([], false).catch(() => {}); }, []);
|
||||
|
||||
|
||||
|
||||
// Group into periods, newest first, and drop the operator's transmissions into
|
||||
// the slot they went out in.
|
||||
@@ -770,7 +821,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{/* The slot clock. Taken from the newest decode's mode, falling back to
|
||||
what the transmit state reports, so it is right the moment anything
|
||||
is heard and keeps running when the band goes quiet. */}
|
||||
<PeriodClock trSec={liveTr} mode={liveMode} />
|
||||
{/* Any receiver on the air colours it: with two decoders the shared
|
||||
txState is whichever reported last, and "somebody here is
|
||||
transmitting" is what the bar has to say. */}
|
||||
<PeriodClock trSec={liveTr} mode={liveMode}
|
||||
tx={!!txState?.transmitting || Object.values(txStates ?? {}).some((s) => s?.transmitting)} />
|
||||
{bandDrift && (
|
||||
<span
|
||||
title={t('dec.bandDriftTip')}
|
||||
@@ -1082,12 +1137,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
<div className="shrink-0 border-b border-border bg-background overflow-hidden">
|
||||
<div className={cn(ROW, 'h-7 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground')}
|
||||
style={{ gridTemplateColumns: template, width: tableW }}>
|
||||
{COLS.map((c, i) => (
|
||||
{cols.map((c, i) => (
|
||||
<span key={c.key}
|
||||
// Not CELL_LAST for the final column: its overflow-hidden would
|
||||
// clip that column's own resize handle.
|
||||
className={cn('relative flex items-center min-w-0 px-2',
|
||||
i < COLS.length - 1 && 'border-r border-border/30',
|
||||
i < cols.length - 1 && 'border-r border-border/30',
|
||||
// The three numeric columns label their own right edge, where the
|
||||
// figures are.
|
||||
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')}
|
||||
@@ -1185,6 +1240,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{hhmmssCompact(d.at)}
|
||||
</span>
|
||||
|
||||
{/* Which receiver heard it — present only while more than one
|
||||
is feeding a merged list. */}
|
||||
{cols.some((c) => c.key === 'rx') && (
|
||||
<span className={cn(CELL, 'text-[11px] text-muted-foreground truncate')}
|
||||
title={d.instance ?? ''}>
|
||||
{decoderName(d.instance)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={cn(CELL, 'justify-end font-mono text-[13px] font-semibold tabular-nums', snrTone(d.snr))}>
|
||||
{d.snr > 0 ? `+${d.snr}` : d.snr}
|
||||
</span>
|
||||
@@ -1251,6 +1315,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{e?.lotw && (
|
||||
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span>
|
||||
)}
|
||||
{/* After the L, which is one letter and always in the same
|
||||
place: a badge in front of it moved the whole column
|
||||
sideways from row to row. */}
|
||||
{isWatched(d.call, watchlist) && (
|
||||
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
|
||||
style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
|
||||
{t('dec.wl')}
|
||||
</span>
|
||||
)}
|
||||
{e?.worked_call && (
|
||||
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
|
||||
{t('dec.wkd')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+14
-14
File diff suppressed because one or more lines are too long
@@ -15,6 +15,12 @@ export function cleanSpotter(s: string): string {
|
||||
// alone instead of guessing wrong.
|
||||
export function inferSpotMode(comment: string, freqHz: number): string {
|
||||
const c = (comment || '').toUpperCase();
|
||||
// SuperFox and Fox/Hound are FT8 — they are WSJT-X's DXpedition transmit
|
||||
// modes, not modes of their own. A spot commented "super fox" fell through to
|
||||
// the band plan and came out DATA, and that verdict is not cosmetic: the
|
||||
// band+mode status is computed from this answer, so a ZD8 on 21.071 read as a
|
||||
// new DATA slot rather than the new FT8 one it is.
|
||||
if (/\bSUPER\s*FOX\b|\bSFOX\b|\bFOX\s*\/?\s*HOUND\b|\bF\/H\b/.test(c)) return 'FT8';
|
||||
if (/\bFT8\b/.test(c)) return 'FT8';
|
||||
if (/\bFT4\b/.test(c)) return 'FT4';
|
||||
if (/\bJS8\b/.test(c)) return 'JS8';
|
||||
|
||||
Reference in New Issue
Block a user