chore: release v0.27.12
This commit is contained in:
+134
-146
@@ -1,7 +1,7 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||
ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
|
||||
ChevronLeft, ChevronUp, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radar, Radio, RadioTower, RefreshCw, Satellite, Send, SatelliteDish, Settings, SlidersHorizontal, SpellCheck, Square, Star, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
GetFlexState, FlexAmpOperate,
|
||||
GetPSKReporterStatus, GetLiveOpenings, GetChaseNew,
|
||||
QSLViaRepairStatus, RepairQSLVia, DismissQSLViaRepair,
|
||||
GetAutoCallStatus, SetAutoCall, SetAutoCallOnly, TakeAutoCallTarget, ResetAutoCall,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs, parseAwardRefs as parseManualRefs, spotRefList , withIOTARef, withRDARef } from '@/lib/awardRefs';
|
||||
@@ -119,9 +120,9 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { GridSquareMap } from '@/components/GridSquareMap';
|
||||
import { loadAutoCall, shouldAutoCall, autoCallKey, type AutoCallSettings } from '@/lib/autocall';
|
||||
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 { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||
@@ -2565,136 +2566,34 @@ export default function App() {
|
||||
|
||||
// ── Auto-call ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Answers a decode without the operator clicking it. The DECISION lives in
|
||||
// lib/autocall (one pure function, so the dangerous part can be read and
|
||||
// argued with); this is only the plumbing that runs it and keys the radio.
|
||||
//
|
||||
// Re-read when Preferences closes, like every other setting edited there.
|
||||
// The DECISION is in the BACKEND (internal/autocall), because it keys a
|
||||
// transmitter: it has to behave the same whether this tab is open or the
|
||||
// window is minimised, and every rule it applies — seven calls, three missed
|
||||
// periods, the ladder — is a Go test rather than something only the air can
|
||||
// check. What is left here is the switch, the readout, and handing over the
|
||||
// station the operator clicks.
|
||||
|
||||
// The named command buttons, re-read when Preferences closes like every other
|
||||
// setting edited there.
|
||||
const [clusterMacros, setClusterMacros] = useState(loadClusterMacros);
|
||||
useEffect(() => { if (!showSettings) setClusterMacros(loadClusterMacros()); }, [showSettings]);
|
||||
const clusterMacrosShown = useMemo(() => visibleClusterMacros(clusterMacros), [clusterMacros]);
|
||||
// Auto-call is withdrawn — it duplicated DXHunter, which answers decodes from
|
||||
// the same shack. This flag is the single place that says so at runtime.
|
||||
const AUTO_CALL_ENABLED = false;
|
||||
const [autoCall, setAutoCall] = useState<AutoCallSettings>(loadAutoCall);
|
||||
useEffect(() => { if (!showSettings) setAutoCall(loadAutoCall()); }, [showSettings]);
|
||||
// When each callsign was last answered, so a station still calling CQ is not
|
||||
// re-answered every slot while the QSO it started is still running.
|
||||
const autoCalledRef = useRef<Map<string, number>>(new Map());
|
||||
// Decodes are scanned once. Without this the same decode is reconsidered on
|
||||
// every status refresh, and a cooldown that has just expired would fire again
|
||||
// on a decode minutes old.
|
||||
const autoSeenRef = useRef<Set<string>>(new Set());
|
||||
// Set when a call goes out, so nothing else fires until the receiver's own
|
||||
// status catches up and `busy` can be trusted again.
|
||||
const autoHoldUntilRef = useRef(0);
|
||||
// The station auto-call is currently working, and when it started.
|
||||
//
|
||||
// This is OpsLog's OWN record of "a QSO is running", and it exists because
|
||||
// deriving that from the sender's Status was not enough: the moment
|
||||
// WSJT-X/JTDX drops the DX call or the Enable-Tx flag between overs — which
|
||||
// they do — the exchange looks finished and the next CQ gets answered,
|
||||
// interleaving two and then three QSOs on one slice. A lock we set ourselves
|
||||
// cannot be cleared by a flag we do not control.
|
||||
//
|
||||
// Released when that station's QSO is logged, when the operator halts or
|
||||
// takes over by clicking a decode, and by the watchdog below.
|
||||
const autoTargetRef = useRef<{ call: string; at: number } | null>(null);
|
||||
// An exchange abandoned mid-way must not lock auto-call out for ever: four
|
||||
// minutes covers a repeated FT8 QSO and still frees the next period soon
|
||||
// enough to matter.
|
||||
const AUTO_TARGET_MAX_MS = 240_000;
|
||||
// Per receiver, when the carrier was last up. Feeds the stale-exchange
|
||||
// backstop below.
|
||||
const lastTxAtRef = useRef<Map<string, number>>(new Map());
|
||||
const [autoCallStatus, setAutoCallStatus] = useState<any>({ enabled: false, target: '', calls: 0, max: 0, misses: 0, max_miss: 0, stopped: false, reason: '' });
|
||||
useEffect(() => {
|
||||
// AUTO-CALL IS WITHDRAWN — DXHunter already answers decodes, and two
|
||||
// programs doing it from one shack key over each other. See lib/autocall.ts.
|
||||
//
|
||||
// Returning here rather than deleting the loop: the decision it implements
|
||||
// is the delicate part, argued over and tested, and worth keeping intact.
|
||||
// The guard is what matters — an operator whose stored preference still
|
||||
// says "enabled" must not have their transmitter keyed by a feature they
|
||||
// can no longer see, let alone switch off.
|
||||
if (!AUTO_CALL_ENABLED) return;
|
||||
if (!autoCall.enabled) return;
|
||||
const now = Date.now();
|
||||
// A QSO is in progress somewhere if ANY receiver is transmitting or still
|
||||
// holding a DX call it has not finished with. Both matter: between overs the
|
||||
// carrier is down but the exchange is not over, and calling someone else
|
||||
// then is exactly the "it never stops" behaviour.
|
||||
const busy = Object.values(txStates).some((tx) => {
|
||||
if (tx?.transmitting) return true;
|
||||
// A DX call still set means the exchange is not finished — but ONLY while
|
||||
// the sender still intends to transmit. The watchdog stops transmission
|
||||
// and leaves the DX call behind, and reading the call alone left auto-call
|
||||
// waiting for a QSO that had already been given up on, for ever.
|
||||
if (!tx?.dx_call?.trim()) return false;
|
||||
if (tx.tx_enabled === false) return false;
|
||||
// Backstop for a sender that never reports the toggle: an exchange with no
|
||||
// transmission for three minutes is over, whatever the DX call still says.
|
||||
// Measured from the last TIME THE CARRIER WAS UP — Status itself arrives
|
||||
// every second and so can never go stale.
|
||||
const last = lastTxAtRef.current.get(tx.instance ?? '');
|
||||
return last === undefined || now - last < 180_000;
|
||||
})
|
||||
// The hold closes the gap between sending a Reply and the receiver saying
|
||||
// it has acted on it — about a second. Without it the OTHER instance still
|
||||
// looks idle in that window and gets a call of its own.
|
||||
|| now < autoHoldUntilRef.current;
|
||||
// Our own lock, evaluated after the watchdog so an abandoned exchange does
|
||||
// not hold the transmitter shut.
|
||||
if (autoTargetRef.current && now - autoTargetRef.current.at > AUTO_TARGET_MAX_MS) {
|
||||
LogUIError('auto-call', `giving up on ${autoTargetRef.current.call} — nothing logged in four minutes`, '');
|
||||
autoTargetRef.current = null;
|
||||
}
|
||||
const locked = busy || autoTargetRef.current !== null;
|
||||
for (const d of decodes) {
|
||||
const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`;
|
||||
if (autoSeenRef.current.has(seenKey)) continue;
|
||||
// A Replay's resent history is display-only: answering a line the far
|
||||
// end already dropped would fail anyway, and doing it at startup — the
|
||||
// moment replays arrive — would be a transmitter firing on old news.
|
||||
if ((d as any).is_new === false) { autoSeenRef.current.add(seenKey); continue; }
|
||||
// Only decodes from the CURRENT period are worth answering: replying to a
|
||||
// slot that has closed asks the far end to match a decode it has dropped.
|
||||
if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; }
|
||||
const e = spotStatus[`${d.call}|${d.band ?? ''}|${(d.mode ?? '').toUpperCase()}`];
|
||||
// NOT marked seen until its status has resolved. Statuses land a few
|
||||
// hundred milliseconds after the decode, so consuming it on first sight
|
||||
// would throw away almost every decode unjudged — the effect re-runs when
|
||||
// spotStatus changes, and this is what lets it look again.
|
||||
if (!e) continue;
|
||||
autoSeenRef.current.add(seenKey);
|
||||
const verdict = shouldAutoCall(autoCall, d, e as any, {
|
||||
busy: locked,
|
||||
calledAt: autoCalledRef.current,
|
||||
now,
|
||||
myCall: station.callsign,
|
||||
});
|
||||
if (!verdict.call) continue;
|
||||
autoCalledRef.current.set(d.call.toUpperCase(), now);
|
||||
autoHoldUntilRef.current = now + 12_000; // an FT8 slot, near enough
|
||||
autoTargetRef.current = { call: d.call.toUpperCase(), at: now };
|
||||
// Same reason as a manual click: put the transmitter on the decode's band
|
||||
// before answering, or a second slice answers on the wrong one.
|
||||
FlexTXOnBand(d.band ?? '').catch(() => {});
|
||||
// Logged, always: an automatic transmission with no record of WHY is the
|
||||
// one thing an operator cannot argue with after the fact.
|
||||
LogUIError('auto-call', `calling ${d.call} — ${verdict.reason}`, '');
|
||||
AnswerDecode(
|
||||
d.instance ?? '', d.ms ?? 0, d.snr, d.dt ?? 0,
|
||||
d.audio_hz ?? 0, d.mode_raw || d.mode || '', d.msg_raw ?? d.msg ?? '', !!d.low_conf,
|
||||
).catch((e2: any) => setError(String(e2?.message ?? e2)));
|
||||
onCallsignInput(d.call, { force: true });
|
||||
break; // one per pass: a period can hold several, and we work one station
|
||||
}
|
||||
// The seen-set is bounded by the same half hour the decode list keeps.
|
||||
if (autoSeenRef.current.size > 20000) autoSeenRef.current.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [decodes, spotStatus, autoCall, txStates]);
|
||||
GetAutoCallStatus().then(setAutoCallStatus).catch(() => {});
|
||||
// Pushed by the engine on every decision, and polled as well: the push is
|
||||
// what makes the counter move the instant a call goes out, the poll is what
|
||||
// recovers a status missed while the window was asleep.
|
||||
const off = EventsOn('autocall:status', (s: any) => setAutoCallStatus(s));
|
||||
const id = window.setInterval(() => { GetAutoCallStatus().then(setAutoCallStatus).catch(() => {}); }, 3000);
|
||||
return () => { off(); window.clearInterval(id); };
|
||||
}, []);
|
||||
const toggleAutoCall = () => {
|
||||
const next = !autoCallStatus?.enabled;
|
||||
setAutoCallStatus((s: any) => ({ ...s, enabled: next }));
|
||||
SetAutoCall(next).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
};
|
||||
|
||||
// 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[]>([]);
|
||||
@@ -2846,6 +2745,14 @@ export default function App() {
|
||||
const [showChaseNew, setShowChaseNew] = useState(() => localStorage.getItem('opslog.showChaseNew') !== '0');
|
||||
const refreshChaseNew = useCallback(() => { GetChaseNew().then(setChaseNewOn).catch(() => {}); }, []);
|
||||
useEffect(() => { refreshChaseNew(); }, [refreshChaseNew]);
|
||||
// The PSK Reporter panel beside the decodes. Open/closed is remembered; the
|
||||
// TARGET is whichever station is being worked, so it follows a click on a
|
||||
// decode and the DX call the digital application reports, and nothing has to
|
||||
// be selected twice.
|
||||
const [pskPanelOpen, setPskPanelOpen] = useState(() => localStorage.getItem('opslog.pskPanel') === '1');
|
||||
useEffect(() => { try { localStorage.setItem('opslog.pskPanel', pskPanelOpen ? '1' : '0'); } catch { /* private mode */ } }, [pskPanelOpen]);
|
||||
const [pskTarget, setPskTarget] = useState('');
|
||||
const [pskTargetMode, setPskTargetMode] = useState('');
|
||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
// Compact rotor widget (Settings → Rotator): dial + SP/LP only. RotorCompass
|
||||
// already draws exactly that when it is given neither presets nor onStop —
|
||||
@@ -3836,11 +3743,6 @@ export default function App() {
|
||||
setTxState(m as TxMsgRow);
|
||||
if (m?.instance) {
|
||||
setTxStates((prev) => ({ ...prev, [m.instance]: m as TxMsgRow }));
|
||||
// When this receiver last actually TRANSMITTED, which is not the same as
|
||||
// when it last spoke: Status arrives about once a second whether the
|
||||
// carrier is up or not, so it can never say how long an exchange has
|
||||
// been stalled.
|
||||
if (m.transmitting) lastTxAtRef.current.set(m.instance, Date.now());
|
||||
}
|
||||
// The period history takes only real transmissions — Status repeats
|
||||
// itself once a second whether the carrier is up or not.
|
||||
@@ -3957,11 +3859,6 @@ export default function App() {
|
||||
try {
|
||||
await LogUDPLoggedADIF(text);
|
||||
await refresh();
|
||||
// The QSO auto-call started has finished — release the lock so the next
|
||||
// CQ can be answered. Matched on the callsign: a QSO logged from
|
||||
// somewhere else must not free a run that is still going.
|
||||
const logged = /<call:d+(?::[^>]*)?>([^<s]+)/i.exec(text)?.[1]?.toUpperCase();
|
||||
if (logged && autoTargetRef.current?.call === logged) autoTargetRef.current = null;
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message ?? e);
|
||||
// A re-broadcast of an already-logged QSO (Log4OM/WSJT-X) is benign —
|
||||
@@ -5182,6 +5079,8 @@ export default function App() {
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('logview.title'), action: 'help.log' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.discord'), action: 'help.discord' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.donate'), action: 'help.donate', accent: true },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||
@@ -5226,7 +5125,8 @@ export default function App() {
|
||||
case 'help.sendlog': sendLogToDeveloper(); break;
|
||||
// Opens in the system browser, NOT the app WebView: a payment page must show
|
||||
// the address bar and padlock the donor knows how to check.
|
||||
case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
|
||||
case 'help.discord': BrowserOpenURL('https://discord.gg/8ZsPDmH9q2'); break;
|
||||
case 'help.donate': BrowserOpenURL('https://www.paypal.com/donate/?hosted_button_id=PDMY7KV99K38S'); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6348,7 +6248,73 @@ export default function App() {
|
||||
// The FT decodes panel, built in ONE place: it is offered both as a tab and as
|
||||
// a Main-view pane, and two copies of this call would be two sets of props to
|
||||
// keep in step.
|
||||
// The dial the decodes are being read against. It has to be the DECODER's,
|
||||
// not the rig's: a report at 14.074.850 is only "+850 Hz in his passband"
|
||||
// measured from the same dial the digital application is using, and on split
|
||||
// or with a transverter the rig's own frequency is a different number.
|
||||
const pskDialHz = useMemo(() => {
|
||||
for (const d of decodes) if (d.dial_hz && d.dial_hz > 0) return d.dial_hz;
|
||||
return txState?.freq_hz ?? 0;
|
||||
}, [decodes, txState?.freq_hz]);
|
||||
|
||||
// Stations WE are decoding that are calling the same DX — the competition at
|
||||
// this end, which PSK Reporter cannot see: it carries who was heard, never
|
||||
// who they were calling.
|
||||
const pskCallers = useMemo(() => {
|
||||
if (!pskTarget) return [] as string[];
|
||||
const want = pskTarget.toUpperCase() + ' ';
|
||||
const seen = new Set<string>();
|
||||
for (const d of decodes) {
|
||||
const msg = (d.msg ?? '').toUpperCase().trim();
|
||||
if (!msg.startsWith(want)) continue;
|
||||
const caller = msg.slice(want.length).trim().split(/\s+/)[0];
|
||||
// "CQ" cannot be a caller, and neither can our own transmission coming
|
||||
// back as a decode of ourselves.
|
||||
if (caller && caller !== 'CQ' && caller !== (station.callsign ?? '').toUpperCase()) seen.add(caller);
|
||||
}
|
||||
return [...seen];
|
||||
}, [decodes, pskTarget, station.callsign]);
|
||||
|
||||
// Follow the station the digital application says it is calling. A decode
|
||||
// clicked here sets the target directly (see onCall); this covers the QSO
|
||||
// started from the other side — WSJT-X's own double-click, or auto-call.
|
||||
useEffect(() => {
|
||||
const dx = (txState?.dx_call ?? '').toUpperCase().trim();
|
||||
if (dx && dx !== pskTarget) {
|
||||
setPskTarget(dx);
|
||||
setPskTargetMode(txState?.mode ?? '');
|
||||
}
|
||||
}, [txState?.dx_call, txState?.mode, pskTarget]);
|
||||
|
||||
const renderDecodesPanel = () => (
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
{renderDecodesList()}
|
||||
</div>
|
||||
{pskPanelOpen ? (
|
||||
<PSKReporterPanel
|
||||
target={pskTarget}
|
||||
mode={pskTargetMode}
|
||||
dialHz={pskDialHz}
|
||||
callers={pskCallers.length}
|
||||
callerCalls={pskCallers}
|
||||
onCollapse={() => setPskPanelOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
// Collapsed to a strip rather than removed: a panel with no way back is
|
||||
// one an operator loses, and the button has to say what it opens.
|
||||
<button type="button" onClick={() => setPskPanelOpen(true)} title={t('psk.show')}
|
||||
className="w-7 shrink-0 border-l border-border bg-card hover:bg-muted flex flex-col items-center gap-2 py-2 text-muted-foreground hover:text-foreground">
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider [writing-mode:vertical-rl]">
|
||||
{t('psk.title')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDecodesList = () => (
|
||||
<DecodesPanel
|
||||
decodes={decodes}
|
||||
txMsgs={txMsgs}
|
||||
@@ -6359,18 +6325,31 @@ export default function App() {
|
||||
// compare with", never "the rig is on no band".
|
||||
rigBand={catState.connected ? (catState.band || '') : ''}
|
||||
myCall={station.callsign}
|
||||
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
|
||||
// a Reply, which is the same thing as double-clicking the line in their
|
||||
// own window.
|
||||
// A DOUBLE click answers the station: it hands the decode back to
|
||||
// WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
|
||||
// their own window. A single click only selects it — see onSelect below,
|
||||
// and the cluster, where the two gestures already mean this.
|
||||
//
|
||||
// Deliberately NOT a rig tune, unlike a cluster spot. On FT8 the whole
|
||||
// band is inside one passband, so moving the dial changes nothing about
|
||||
// who gets answered — and it would only fight the digital application
|
||||
// for the VFO. The entry is still filled, so the QSO can be logged here.
|
||||
// One click: take the station, transmit nothing. The entry is filled and
|
||||
// the panels follow it, exactly as clicking a cluster spot does — so a
|
||||
// row can be inspected, looked up and read about without keying up.
|
||||
onSelect={(d) => {
|
||||
setPskTarget((d.call ?? '').toUpperCase());
|
||||
setPskTargetMode(d.mode ?? '');
|
||||
onCallsignInput(d.call, { force: true });
|
||||
}}
|
||||
onCall={(d) => {
|
||||
// The operator has picked a station: that is now the QSO in progress, so
|
||||
// auto-call must not answer someone else over the top of it.
|
||||
autoTargetRef.current = { call: (d.call ?? '').toUpperCase(), at: Date.now() };
|
||||
// The station being answered is also the one worth analysing: the PSK
|
||||
// Reporter panel follows the click rather than asking for a second one.
|
||||
setPskTarget((d.call ?? '').toUpperCase());
|
||||
setPskTargetMode(d.mode ?? '');
|
||||
// Auto-call adopts the station: the click chooses WHO, the watchdogs
|
||||
// still decide how long it is called for.
|
||||
TakeAutoCallTarget(d.call ?? '', d.band ?? '', d.mode ?? '').catch(() => {});
|
||||
onCallsignInput(d.call, { force: true });
|
||||
// With two slices on two bands, the Reply reaches the right INSTANCE but
|
||||
// the radio still transmits on whichever slice holds the TX flag. Move
|
||||
@@ -6389,7 +6368,6 @@ export default function App() {
|
||||
// buffer goes too or the next flush would put back what was just cleared.
|
||||
onClear={(instance) => {
|
||||
if (!instance) {
|
||||
autoTargetRef.current = null;
|
||||
pendingDecodesRef.current = [];
|
||||
setDecodes([]);
|
||||
setTxMsgs([]);
|
||||
@@ -6407,10 +6385,20 @@ 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.
|
||||
autoTargetRef.current = null;
|
||||
// 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(() => {});
|
||||
HaltDecodeTx(instance, false).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
}}
|
||||
autoCallOn={!!autoCallStatus?.enabled}
|
||||
onToggleAutoCall={toggleAutoCall}
|
||||
autoCall={autoCallStatus}
|
||||
autoCallOnly={autoCallStatus?.only ?? ''}
|
||||
onSetAutoCallOnly={(list) => {
|
||||
setAutoCallStatus((st: any) => ({ ...st, only: list.toUpperCase() }));
|
||||
SetAutoCallOnly(list).catch((e: any) => setError(String(e?.message ?? e)));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 { decoderName } from '@/lib/decoderName';
|
||||
|
||||
export type Decode = {
|
||||
call: string;
|
||||
@@ -99,6 +100,9 @@ interface Props {
|
||||
// the decoder announces — see the drift warning.
|
||||
rigBand?: string;
|
||||
onCall: (d: Decode) => void;
|
||||
// A single click: take the station without transmitting — fill the entry, and
|
||||
// point the panels at it. Absent, a click falls back to onCall.
|
||||
onSelect?: (d: Decode) => void;
|
||||
myCall?: string;
|
||||
// Drop every decode and transmit message held for this panel. The list is a
|
||||
// live view, not data — clearing it costs nothing but the seconds until the
|
||||
@@ -115,6 +119,12 @@ interface Props {
|
||||
// machine off is not something to go hunting through a settings tree for.
|
||||
autoCallOn?: boolean;
|
||||
onToggleAutoCall?: () => void;
|
||||
// The engine's own account of what it is doing, straight from the backend.
|
||||
autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
|
||||
// The chase list, here as well as in Preferences: naming the station you are
|
||||
// waiting for is done WHILE watching the band, not in a settings tree.
|
||||
autoCallOnly?: string;
|
||||
onSetAutoCallOnly?: (list: string) => void;
|
||||
}
|
||||
|
||||
// The "new" categories, as toggle badges — the same idea and the same colours as
|
||||
@@ -542,12 +552,21 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall }: Props) {
|
||||
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly }: 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/
|
||||
// like every other portable preference.
|
||||
const [colw, setColw] = useState<ColWidths>(loadWidths);
|
||||
// The chase list as TYPED. Re-seeded whenever the stored value changes —
|
||||
// from Preferences, or from another window — but never while the box has the
|
||||
// focus, or a status arriving mid-word would rewrite what is being typed.
|
||||
const [onlyText, setOnlyText] = useState(autoCallOnly ?? '');
|
||||
useEffect(() => {
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
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) => {
|
||||
@@ -710,7 +729,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
}
|
||||
return instances.map((inst) => ({
|
||||
key: inst,
|
||||
label: inst,
|
||||
// What the program is called, not the id it announces — see decoderName.
|
||||
label: decoderName(inst),
|
||||
tx: txStates?.[inst],
|
||||
periods: buildPeriods(
|
||||
filtered.filter((d) => (d.instance ?? '') === inst),
|
||||
@@ -757,7 +777,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
className="inline-flex items-center gap-1 rounded-full border border-warning px-2 py-0.5 text-[11px] font-semibold text-warning">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
{t('dec.bandDrift', {
|
||||
app: driftInstance || t('dec.bandDriftApp'),
|
||||
app: decoderName(driftInstance) || t('dec.bandDriftApp'),
|
||||
dec: decoderBand.toUpperCase(),
|
||||
rig: (rigBand ?? '').toUpperCase(),
|
||||
})}
|
||||
@@ -889,6 +909,54 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
filter. Halt goes LAST — it is the one that must be findable without
|
||||
reading, and the end of the row is the one position that never moves
|
||||
as filters come and go. */}
|
||||
{/* Auto-call. Deliberately next to Halt: the two belong together, and
|
||||
what it is doing right now — which station, how many calls of how
|
||||
many — is on the button itself, because a thing that keys the
|
||||
transmitter must never be a switch with no readout. */}
|
||||
{onToggleAutoCall && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAutoCall}
|
||||
title={autoCall?.stopped ? t('dec.autoStoppedTip') : t('dec.autoCallTip')}
|
||||
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border font-medium',
|
||||
!autoCallOn
|
||||
? 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
: autoCall?.stopped
|
||||
? 'border-warning bg-warning text-warning-foreground'
|
||||
: 'border-success bg-success text-success-foreground')}
|
||||
>
|
||||
<Bot className="size-3.5" />
|
||||
{t('dec.autoCall')}
|
||||
{autoCallOn && autoCall?.target && (
|
||||
<span className="font-mono text-xs">
|
||||
{autoCall.target} {autoCall.calls}/{autoCall.max}
|
||||
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* The chase list. Raw text while typing, committed on blur or Enter:
|
||||
the stored value is upper-cased and trimmed, and binding the box to
|
||||
that makes the space bar look dead — in a field whose whole purpose
|
||||
is a list separated by spaces. */}
|
||||
{onSetAutoCallOnly && (
|
||||
<input
|
||||
type="text"
|
||||
value={onlyText}
|
||||
onChange={(e) => setOnlyText(e.target.value)}
|
||||
onBlur={() => { if (onlyText.toUpperCase() !== (autoCallOnly ?? '')) onSetAutoCallOnly(onlyText); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
||||
if (e.key === 'Escape') setOnlyText(autoCallOnly ?? '');
|
||||
}}
|
||||
placeholder={t('dec.chasePh')}
|
||||
title={t('dec.chaseTip')}
|
||||
className={cn('h-8 w-44 rounded-lg border px-2 text-sm font-mono uppercase bg-background',
|
||||
(autoCallOnly ?? '').trim()
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-border text-muted-foreground')}
|
||||
/>
|
||||
)}
|
||||
{onHalt && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -955,7 +1023,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
<span className="flex-1" />
|
||||
{txState.band && <span className="text-xs text-muted-foreground shrink-0">{txState.band}</span>}
|
||||
{txState.instance && instances.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">{txState.instance}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">{decoderName(txState.instance)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1095,7 +1163,12 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
<button
|
||||
key={`${d.call}-${d.freq_hz}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onCall(d)}
|
||||
// ONE click selects, TWO transmit — the cluster's rule, and
|
||||
// the only safe one here: a single click used to hand the
|
||||
// decode straight to WSJT-X as a Reply, so brushing a row
|
||||
// while reading the band started calling a station.
|
||||
onClick={() => (onSelect ?? onCall)(d)}
|
||||
onDoubleClick={() => onCall(d)}
|
||||
title={t('dec.callTitle', { call: d.call })}
|
||||
style={{ gridTemplateColumns: template, width: tableW }}
|
||||
className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead';
|
||||
import { gridToLatLon, greatCirclePoints, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -124,8 +124,12 @@ export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid
|
||||
const age = now - Date.parse(d.at);
|
||||
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
|
||||
const colour = bandColour(d.band);
|
||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48);
|
||||
L.polyline(pts as L.LatLngExpression[], {
|
||||
// Cut at the antimeridian: this map shows ONE world, so a path running
|
||||
// past ±180 has to leave one edge and come back at the other. Without it
|
||||
// every arc out of VK or ZL was drawn into the blank space off the side
|
||||
// of the map, its far end sitting alone on the opposite coast.
|
||||
const pts = splitAtAntimeridian(greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48));
|
||||
L.polyline(pts as L.LatLngExpression[][], {
|
||||
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
|
||||
}).addTo(layer);
|
||||
L.circleMarker([to.lat, to.lon], {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
||||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, IcomRecallBand,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -68,8 +68,9 @@ const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
||||
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||||
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||||
|
||||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||||
// the plain SetFrequency command — no band-stacking codes needed.
|
||||
// These frequencies are the FALLBACK: with the band stacking registers switched
|
||||
// on the radio is asked where the operator last was instead, and one of these is
|
||||
// only sent for a band or a model whose register cannot be read.
|
||||
//
|
||||
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||||
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||||
@@ -392,6 +393,19 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
const [st, setSt] = useState<IcomState>(ZERO);
|
||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||
const [tuning, setTuning] = useState(false);
|
||||
// Band buttons: recall the radio's own band stacking register instead of
|
||||
// sending a frequency picked here. Remembered per operator, not per session —
|
||||
// it is a preference about how a button behaves, and having to set it again
|
||||
// at every launch would make it not worth having.
|
||||
const [bandStack, setBandStack] = useState(() => localStorage.getItem('opslog.icomBandStack') === '1');
|
||||
const toggleBandStack = () => setBandStack((v) => {
|
||||
const n = !v;
|
||||
try { localStorage.setItem('opslog.icomBandStack', n ? '1' : '0'); } catch { /* private mode */ }
|
||||
return n;
|
||||
});
|
||||
// Which register each band was last recalled from, so pressing the same band
|
||||
// again walks 1 → 2 → 3 → 1, exactly as the radio's own band key does.
|
||||
const bandRegRef = useRef<Record<string, number>>({});
|
||||
const txRef = useRef(false);
|
||||
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||||
|
||||
@@ -400,6 +414,18 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||||
};
|
||||
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||||
|
||||
// A band button. With the stacking registers on, ask the radio where the
|
||||
// operator last was on that band; pressing the band it is already on steps to
|
||||
// the next register, and the fixed frequency below is the fallback for a band
|
||||
// or a model whose register the backend will not read — never a dead button.
|
||||
const bandClick = (b: Band, here: boolean) => {
|
||||
if (!bandStack) { SetCATFrequency(b.hz).catch(() => {}); return; }
|
||||
const reg = here ? (bandRegRef.current[b.l] ?? 1) % 3 + 1 : 1;
|
||||
IcomRecallBand(b.l, reg)
|
||||
.then(() => { bandRegRef.current[b.l] = reg; load(); })
|
||||
.catch(() => SetCATFrequency(b.hz).catch(() => {}));
|
||||
};
|
||||
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||||
const refresh = async () => {
|
||||
@@ -592,11 +618,16 @@ export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (r
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{/* Band buttons + antenna selection. */}
|
||||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||||
<label className="flex items-center gap-1.5 mb-1.5 text-[11px] text-muted-foreground cursor-pointer select-none"
|
||||
title={t('icmp.bandStackHint')}>
|
||||
<input type="checkbox" checked={bandStack} onChange={toggleBandStack} className="accent-primary" />
|
||||
{t('icmp.bandStack')}
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{bandsFor(st.model).map((b) => {
|
||||
const here = bandOfHz(mainHz) === b.l;
|
||||
return (
|
||||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||||
<button key={b.l} type="button" onClick={() => bandClick(b, here)}
|
||||
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||||
here
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// PSKReporterPanel — can the station I am about to call actually hear me?
|
||||
//
|
||||
// The decodes list to the left says who is transmitting. It cannot say anything
|
||||
// about the other direction, and on FT8 that is the whole question: the DX's
|
||||
// pileup is invisible from here, and a station whose region is not open to
|
||||
// yours will not hear you however many times you call.
|
||||
//
|
||||
// Every number here comes from PSK Reporter — reports uploaded by ordinary
|
||||
// stations saying "I decoded X" — over a five-minute window. Nothing is
|
||||
// inferred and nothing is remembered: when the window empties the panel says it
|
||||
// does not know, which is the honest answer and the reason each block also says
|
||||
// what it is measuring.
|
||||
//
|
||||
// The backend (internal/pskrtgt) does the analysis; this draws it and polls.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { GetPSKAnalysis, SetPSKTarget } from '../../wailsjs/go/main/App';
|
||||
|
||||
export type PSKEntry = {
|
||||
call: string;
|
||||
grid?: string;
|
||||
snr: number;
|
||||
offset_hz: number;
|
||||
age_sec: number;
|
||||
};
|
||||
|
||||
export type PSKAnalysis = {
|
||||
target?: string;
|
||||
mode?: string;
|
||||
enabled: boolean;
|
||||
online: boolean;
|
||||
spots: number;
|
||||
he_me: boolean;
|
||||
he_me_seconds: number;
|
||||
he_me_snr: number;
|
||||
he_me_offset_hz: number;
|
||||
target_uploads: boolean;
|
||||
target_grid?: string;
|
||||
near_him_count: number;
|
||||
near_him_top?: PSKEntry[];
|
||||
from_my_area_count: number;
|
||||
from_my_area_top?: PSKEntry[];
|
||||
path_open: boolean;
|
||||
heard_by_count: number;
|
||||
heard_near_me: number;
|
||||
heard_near_me_top?: PSKEntry[];
|
||||
decoded_by_count: number;
|
||||
decoded_by_top?: PSKEntry[];
|
||||
decoded_by_calls?: string[];
|
||||
pileup_count: number;
|
||||
dial_hz: number;
|
||||
ceiling_hz: number;
|
||||
decodes_in_window: number;
|
||||
bins?: { offset_hz: number; count: number; avg_snr: number }[];
|
||||
suggested_offset: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
// The station to analyse and the mode it was heard on. Set by clicking a
|
||||
// decode, or by whoever the digital application says it is calling.
|
||||
target: string;
|
||||
mode?: string;
|
||||
// The operator's own dial, which is what turns a report's frequency into an
|
||||
// audio offset. Without it the passband block has nothing to say.
|
||||
dialHz?: number;
|
||||
// The local decodes, for "callers you hear": stations WE are decoding that
|
||||
// are calling the same DX. That is the competition measured at this end,
|
||||
// which no amount of PSK Reporter data can show.
|
||||
callers: number;
|
||||
callerCalls?: string[];
|
||||
onCollapse: () => void;
|
||||
}
|
||||
|
||||
// The passband strip: 60 Hz bins, drawn from 200 Hz to 4000 Hz. The bin edges
|
||||
// have to match the backend's alignment exactly — it keys them on multiples of
|
||||
// 60 from zero, so a strip starting at 200 would ask for edges that never
|
||||
// exist and draw an empty histogram over a busy passband.
|
||||
const LO = 200, HI = 4000, STEP = 60;
|
||||
const FIRST_EDGE = Math.floor(LO / STEP) * STEP;
|
||||
const COLS = Math.floor((HI - FIRST_EDGE) / STEP);
|
||||
|
||||
export function PSKReporterPanel({ target, mode, dialHz, callers, callerCalls, onCollapse }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [a, setA] = useState<PSKAnalysis | null>(null);
|
||||
|
||||
// One second, matching the panel's own claim about how fresh it is. The call
|
||||
// is a snapshot of an in-memory window — no query and no network of its own.
|
||||
useEffect(() => {
|
||||
let stop = false;
|
||||
const tick = () => {
|
||||
GetPSKAnalysis().then((r) => { if (!stop) setA(r as unknown as PSKAnalysis); }).catch(() => {});
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => { stop = true; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// The target is re-asserted rather than sent once. The backend treats an
|
||||
// unchanged callsign as a no-op, and this way a broker that dropped while
|
||||
// nobody was looking comes back on its own instead of leaving a panel that
|
||||
// is permanently, silently empty.
|
||||
useEffect(() => {
|
||||
SetPSKTarget(target ?? '', mode ?? '', dialHz ?? 0).catch(() => {});
|
||||
if (!target) return;
|
||||
const id = window.setInterval(() => {
|
||||
SetPSKTarget(target, mode ?? '', dialHz ?? 0).catch(() => {});
|
||||
}, 15000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [target, mode, dialHz]);
|
||||
|
||||
const bins = a?.bins ?? [];
|
||||
const maxCount = useMemo(() => bins.reduce((m, b) => Math.max(m, b.count || 0), 0) || 1, [bins]);
|
||||
const byOffset = useMemo(() => {
|
||||
const m = new Map<number, { count: number; avg_snr: number }>();
|
||||
for (const b of bins) m.set(b.offset_hz, b);
|
||||
return m;
|
||||
}, [bins]);
|
||||
const columns = useMemo(() => {
|
||||
const out: { edge: number; count: number; snr: number | null }[] = [];
|
||||
for (let i = 0; i < COLS; i++) {
|
||||
const edge = FIRST_EDGE + i * STEP;
|
||||
const b = byOffset.get(edge);
|
||||
out.push({ edge, count: b?.count ?? 0, snr: b?.avg_snr ?? null });
|
||||
}
|
||||
return out;
|
||||
}, [byOffset]);
|
||||
|
||||
// Confirmed pileup: a station we hear calling this DX that the DX has also
|
||||
// decoded. Two independent pieces of evidence, so it is the one number here
|
||||
// that is not a proxy for anything.
|
||||
const confirmed = useMemo(() => {
|
||||
const heard = new Set((a?.decoded_by_calls ?? []).map((c) => c.toUpperCase()));
|
||||
return (callerCalls ?? []).filter((c) => heard.has(c.toUpperCase())).length;
|
||||
}, [a?.decoded_by_calls, callerCalls]);
|
||||
|
||||
const snr = (v: number) => `${v > 0 ? '+' : ''}${v}`;
|
||||
|
||||
const Tile = ({ label, value, foot, tone, title }: {
|
||||
label: string; value: number | string; foot: string; tone: string; title?: string;
|
||||
}) => (
|
||||
<div className="px-2 py-1.5 rounded-md bg-muted/40 border border-border/60" title={title}>
|
||||
<div className="text-[9px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className={cn('text-lg font-bold leading-tight', tone)}>{value}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{foot}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-[340px] shrink-0 flex flex-col min-h-0 border-l border-border bg-card">
|
||||
{/* Header: what is being watched, and whether the feed is actually up. A
|
||||
panel full of zeros means one of two very different things. */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 border-b border-border shrink-0">
|
||||
<Activity className="size-4 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('psk.title')}
|
||||
</span>
|
||||
{target && <span className="text-xs font-mono text-foreground truncate">→ {target}</span>}
|
||||
<span className="ml-auto flex items-center gap-2 text-[10px] shrink-0">
|
||||
{a?.target && a.spots > 0 && (
|
||||
<span className="text-muted-foreground" title={t('psk.spotsTip')}>{t('psk.spots', { n: a.spots })}</span>
|
||||
)}
|
||||
{a?.enabled === false
|
||||
? <span className="text-muted-foreground">{t('psk.off')}</span>
|
||||
: a?.online
|
||||
? <span className="text-success">● {t('psk.online')}</span>
|
||||
: <span className="text-muted-foreground">○ {t('psk.offline')}</span>}
|
||||
<button type="button" onClick={onCollapse} title={t('psk.hide')}
|
||||
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground">
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2.5 py-2 space-y-3">
|
||||
{a?.enabled === false ? (
|
||||
<p className="text-xs text-muted-foreground italic">{t('psk.enableHint')}</p>
|
||||
) : !target ? (
|
||||
<p className="text-xs text-muted-foreground italic">{t('psk.pickHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* ── The answer ──────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('shrink-0 size-8 rounded-full border flex items-center justify-center text-base',
|
||||
a?.he_me ? 'bg-success/20 border-success/50 text-success'
|
||||
: a?.path_open ? 'bg-warning/20 border-warning/50 text-warning'
|
||||
: 'bg-muted border-border text-muted-foreground')}>
|
||||
{a?.he_me ? '✓' : a?.path_open ? '≈' : '·'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{a?.he_me ? (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-success">{t('psk.heardYou', { s: a.he_me_seconds })}</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground">
|
||||
{snr(a.he_me_snr)} dB{a.he_me_offset_hz > 0 ? ` @ +${a.he_me_offset_hz} Hz` : ''}
|
||||
</div>
|
||||
</>
|
||||
) : a?.path_open ? (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-warning">{t('psk.pathOpen')}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t('psk.pathOpenSub', { n: a.from_my_area_count })}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('psk.notYet')}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{t('psk.notYetSub')}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Your signal reported next to him. Only when he has not decoded
|
||||
you himself — that is strictly stronger evidence, and two
|
||||
banners saying the same thing differently is noise. */}
|
||||
{!a?.he_me && (a?.near_him_count ?? 0) > 0 && a?.target_grid && (
|
||||
<div className="px-2 py-1.5 rounded-md bg-info/10 border border-info/30">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-0.5">
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold text-info">
|
||||
✓ {t('psk.nearHim', { g: a.target_grid })}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t('psk.nRx', { n: a.near_him_count })}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2 font-mono text-[11px]">
|
||||
{(a.near_him_top ?? []).map((h) => (
|
||||
<span key={h.call} className="text-info" title={`${h.call} ${h.grid ?? ''} · ${h.age_sec}s`}>
|
||||
{h.call} <span className="text-muted-foreground">{snr(h.snr)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── The four numbers ────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Tile label={t('psk.tFromArea')} value={a?.from_my_area_count ?? 0} foot={t('psk.tFromAreaFoot')}
|
||||
tone="text-success"
|
||||
title={(a?.from_my_area_top ?? []).map((h) => `${h.call} (${h.grid ?? '?'}) ${snr(h.snr)}`).join(' · ')} />
|
||||
<Tile label={t('psk.tPileup')} value={a?.pileup_count ?? 0} foot={t('psk.tPileupFoot')}
|
||||
tone="text-primary" title={t('psk.tPileupTip')} />
|
||||
<Tile label={t('psk.tHeardNear')} value={a?.heard_near_me ?? 0} foot={t('psk.tHeardNearFoot')}
|
||||
tone="text-info"
|
||||
title={t('psk.tHeardNearTip', { n: a?.heard_by_count ?? 0 })} />
|
||||
<Tile label={t('psk.tCallers')} value={confirmed > 0 ? `${callers} (${confirmed})` : callers}
|
||||
foot={confirmed > 0 ? t('psk.tCallersFootConf') : t('psk.tCallersFoot')}
|
||||
tone="text-warning" title={t('psk.tCallersTip')} />
|
||||
</div>
|
||||
|
||||
{/* The one thing that turns an empty panel from a verdict into a
|
||||
missing measurement. */}
|
||||
{a?.target_uploads ? (
|
||||
<div className="text-[11px] text-success">✓ {t('psk.uploads')}</div>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 rounded-md bg-warning/10 border border-warning/30 text-[11px] text-warning">
|
||||
⚠ {t('psk.noUploads', { c: target })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Who near you he is hearing ──────────────────────────── */}
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">{t('psk.fromAreaList')}</div>
|
||||
{(a?.from_my_area_top ?? []).length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
|
||||
{(a?.from_my_area_top ?? []).slice(0, 4).map((h) => (
|
||||
<div key={h.call} className="flex items-baseline gap-2 truncate"
|
||||
title={t('psk.rowTip', { c: h.call, g: h.grid ?? '?', s: h.age_sec, d: snr(h.snr) })}>
|
||||
<span className="font-semibold text-foreground w-20 truncate">{h.call}</span>
|
||||
<span className="text-muted-foreground w-12">({(h.grid ?? '?').slice(0, 4)})</span>
|
||||
<span className="text-success w-14">{snr(h.snr)} dB</span>
|
||||
{h.offset_hz > 0 && h.offset_hz < 10000 && (
|
||||
<span className="ml-auto text-muted-foreground whitespace-nowrap">@ +{h.offset_hz} Hz</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-muted-foreground italic">{t('psk.fromAreaEmpty')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── His passband ────────────────────────────────────────── */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('psk.passband')}</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
{(a?.ceiling_hz ?? 0) > 0
|
||||
? t('psk.ceiling', { hz: a!.ceiling_hz, n: a!.decodes_in_window })
|
||||
: (a?.decodes_in_window ?? 0) > 0 ? t('psk.noDial') : t('psk.noDecodes')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex items-end gap-px h-10 rounded bg-muted/40 px-1 py-0.5 overflow-hidden">
|
||||
{columns.map((c) => {
|
||||
const ratio = c.count / maxCount;
|
||||
return (
|
||||
<div key={c.edge}
|
||||
className={cn('flex-1 min-w-0 rounded-sm',
|
||||
c.count === 0 ? 'bg-border'
|
||||
: ratio > 0.66 ? 'bg-primary'
|
||||
: ratio > 0.33 ? 'bg-primary/70' : 'bg-primary/40')}
|
||||
style={{ height: `${Math.max(2, Math.round(ratio * 36))}px` }}
|
||||
title={`${c.edge}-${c.edge + STEP} Hz · ${c.count}${c.snr !== null ? ` @ ${c.snr.toFixed(0)} dB` : ''}`} />
|
||||
);
|
||||
})}
|
||||
{(a?.suggested_offset ?? 0) > 0 && (
|
||||
<div className="absolute top-0 bottom-0 w-0.5 bg-success pointer-events-none"
|
||||
style={{ left: `${((a!.suggested_offset - LO) / (HI - LO)) * 100}%`, boxShadow: '0 0 4px currentColor' }}
|
||||
title={t('psk.tryOffset', { hz: a!.suggested_offset })} />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-3 mt-0.5 text-[9px] font-mono text-muted-foreground">
|
||||
{[1000, 2000, 3000, 4000].map((hz) => (
|
||||
<span key={hz} className="absolute whitespace-nowrap"
|
||||
style={{ left: `${((hz - LO) / (HI - LO)) * 100}%`, transform: `translateX(${hz === HI ? '-100%' : '-50%'})` }}>
|
||||
{hz}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{(a?.suggested_offset ?? 0) > 0 && (
|
||||
<div className="text-center text-[11px] font-mono text-success mt-0.5">
|
||||
🎯 {t('psk.tryOffset', { hz: a!.suggested_offset })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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, GetGridCacheStatus, GetLinkedAmps, SetLinkedAmps, GetSpotTTLMinutes, SetSpotTTLMinutes, GetSpotMax, SetSpotMax,
|
||||
GetBandOpenSettings, SaveBandOpenSettings, GetGridScopeSettings, SaveGridScopeSettings, GetPSKReporterStatus, GetChaseNewGrids, SetChaseNewGrids, GetChaseNew, SetChaseNew, GetPSKTargetSettings, SavePSKTargetSettings, GetAutoCallSettings, SaveAutoCallSettings, 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';
|
||||
@@ -484,6 +484,8 @@ const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: str
|
||||
'dark-indigo': { bg: '#0e0f1f', card: '#181a30', accent: '#7c6cff' },
|
||||
'dark-teal': { bg: '#061c21', card: '#0d2c33', accent: '#22d3ee' },
|
||||
'dark-plum': { bg: '#180f1e', card: '#251830', accent: '#f472b6' },
|
||||
'dxhunter': { bg: '#0f172a', card: '#1e293b', accent: '#3b82f6' },
|
||||
'dxhunter-orange': { bg: '#0f172a', card: '#1e293b', accent: '#f97316' },
|
||||
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
||||
};
|
||||
|
||||
@@ -2086,6 +2088,21 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
const [chaseStateOn, setChaseStateOn] = useState(() => localStorage.getItem('opslog.chaseState') !== '0');
|
||||
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 });
|
||||
// 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('');
|
||||
const [contestPattern, setContestPattern] = useState('');
|
||||
const saveAC = async (next: any) => {
|
||||
setAc(next);
|
||||
try { await SaveAutoCallSettings(next); } catch { /* the toolbar shows what the engine is doing */ }
|
||||
};
|
||||
const savePSKTgt = async (next: any) => {
|
||||
setPskTgt(next);
|
||||
try { await SavePSKTargetSettings(next); } catch { /* the panel itself reports what the feed is doing */ }
|
||||
};
|
||||
const [spotTTL, setSpotTTL] = useState(0);
|
||||
const [spotTTLText, setSpotTTLText] = useState('0');
|
||||
const [spotMaxText, setSpotMaxText] = useState('1000');
|
||||
@@ -2113,6 +2130,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
writeUiPref('opslog.chaseGrids', g ? '1' : '0');
|
||||
} catch { /* defaults stand */ }
|
||||
try { setChaseNew(await GetChaseNew()); } catch { /* defaults stand */ }
|
||||
try { setPskTgt(await GetPSKTargetSettings()); } catch { /* defaults stand */ }
|
||||
try { setAc(await GetAutoCallSettings()); } catch { /* defaults stand */ }
|
||||
try { setContestCalls(await GetWatchlistContestCalls()); } catch { /* defaults stand */ }
|
||||
try { setContestPattern(await GetWatchlistContestPattern()); } catch { /* defaults stand */ }
|
||||
try { setLinkedAmps((await GetLinkedAmps()) ?? []); } catch { /* defaults stand */ }
|
||||
try { const n = await GetSpotTTLMinutes(); setSpotTTL(n); setSpotTTLText(String(n)); } catch { /* defaults stand */ }
|
||||
try { const n = await GetSpotMax(); setSpotMaxText(String(n)); } catch { /* defaults stand */ }
|
||||
@@ -5357,6 +5378,135 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
onCheckedChange={(c) => { setChaseNew(!!c); SetChaseNew(!!c).catch(() => {}); }} />
|
||||
<span>{t('chn.option')} <span className="text-xs text-muted-foreground">{t('chn.optionHelp')}</span></span>
|
||||
</label>
|
||||
{/* The same radius the band-opening watch uses — one feed, one
|
||||
circle — but reachable from here, because an operator who only
|
||||
wants the chase list would otherwise have to find it inside a
|
||||
watch they never switched on. It is the setting that decides
|
||||
whether this list has anything in it at all: where stations are
|
||||
far apart, 300 km can hold no receivers whatsoever. */}
|
||||
{chaseNew && (
|
||||
<div className="flex items-center gap-2 flex-wrap pl-6">
|
||||
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
|
||||
<Input
|
||||
type="number" min={25} max={3000} step={25}
|
||||
className="w-24 h-7 text-xs"
|
||||
defaultValue={bandOpen.near_km ?? 300}
|
||||
key={`cnk-${bandOpen.near_km ?? 300}`}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v !== bandOpen.near_km) saveBandOpen({ ...bandOpen, near_km: v });
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">km</span>
|
||||
<span className="text-xs text-muted-foreground">{t('chn.nearKmHint')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PSK Reporter analysis of the station being called. Same service as
|
||||
the two options above, opposite question: those ask what is being
|
||||
heard around here, this asks whether ONE station can hear you. */}
|
||||
<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={pskTgt.enabled} className="mt-0.5"
|
||||
onCheckedChange={(c) => savePSKTgt({ ...pskTgt, enabled: !!c })} />
|
||||
<span>{t('psk.setEnable')} <span className="text-xs text-muted-foreground">{t('psk.setEnableHint')}</span></span>
|
||||
</label>
|
||||
{pskTgt.enabled && (
|
||||
<div className="pl-6 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{t('psk.setScope')}</span>
|
||||
<Select value={pskTgt.scope} onValueChange={(v) => savePSKTgt({ ...pskTgt, scope: v })}>
|
||||
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="target">{t('psk.setScopeTarget')}</SelectItem>
|
||||
<SelectItem value="band">{t('psk.setScopeBand')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('psk.setScopeHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contest — how a special-event fleet finds its way onto the watch
|
||||
list on its own. Two halves, because a fleet has two kinds of
|
||||
member. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<div className="text-sm font-medium">{t('wlc.title')}</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{t('wlc.pattern')}</span>
|
||||
<Input className="h-7 w-32 text-xs font-mono uppercase"
|
||||
defaultValue={contestPattern} key={`wlcp-${contestPattern}`}
|
||||
placeholder={t('wlc.patternPh')}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.toUpperCase().trim();
|
||||
if (v !== contestPattern) { setContestPattern(v); void SetWatchlistContestPattern(v); }
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
|
||||
<span className="text-xs text-muted-foreground">{t('wlc.patternHint')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">{t('wlc.calls')}</span>
|
||||
<textarea
|
||||
className="w-full h-24 rounded-md border border-border bg-background p-2 text-xs font-mono uppercase"
|
||||
value={contestCalls}
|
||||
placeholder={t('wlc.callsPh')}
|
||||
onChange={(e) => setContestCalls(e.target.value)}
|
||||
onBlur={(e) => void SetWatchlistContestCalls(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('wlc.callsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-call. Last in this section, and behind a warning: it is the
|
||||
only setting in OpsLog that transmits without being asked to. */}
|
||||
<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={ac.enabled} className="mt-0.5"
|
||||
onCheckedChange={(c) => saveAC({ ...ac, enabled: !!c })} />
|
||||
<span>{t('ac.enable')} <span className="text-xs text-muted-foreground">{t('ac.enableHint')}</span></span>
|
||||
</label>
|
||||
<p className="text-xs text-warning">{t('ac.warn')}</p>
|
||||
{ac.enabled && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('ac.ladder')}</p>
|
||||
{/* A LIST: several callsigns, spaces or commas. Committed on
|
||||
blur or Enter and kept as raw text while typing — binding the
|
||||
box to the parsed value is what makes the space key look dead,
|
||||
and space is the one key this field needs. */}
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.only')}</span>
|
||||
<Input className="h-7 w-72 text-xs font-mono uppercase"
|
||||
defaultValue={ac.only ?? ''} key={`aco-${ac.only ?? ''}`}
|
||||
placeholder={t('ac.onlyPh')}
|
||||
onBlur={(e) => saveAC({ ...ac, only: e.target.value.toUpperCase() })}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
|
||||
<span className="text-xs text-muted-foreground mt-1.5">{t('ac.onlyHint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{([
|
||||
['attempts', t('ac.attempts'), 1, 30],
|
||||
['watched_attempts', t('ac.watchedAttempts'), 1, 60],
|
||||
['misses', t('ac.misses'), 1, 10],
|
||||
['max_rounds', t('ac.rounds'), 1, 10],
|
||||
['rest_min', t('ac.rest'), 1, 60],
|
||||
] as [string, string, number, number][]).map(([k, label, min, max]) => (
|
||||
<span key={k} className="inline-flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<Input type="number" min={min} max={max} className="h-7 w-16 text-xs"
|
||||
defaultValue={(ac as any)[k]} key={`ac-${k}-${(ac as any)[k]}`}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v >= min && v <= max && v !== (ac as any)[k]) saveAC({ ...ac, [k]: v });
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -5601,7 +5751,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{t('bo.nearKm')}</span>
|
||||
<Input
|
||||
type="number" min={25} max={1000} step={25}
|
||||
type="number" min={25} max={3000} step={25}
|
||||
className="w-24 h-7 text-xs"
|
||||
defaultValue={bandOpen.near_km ?? 300}
|
||||
key={`nk-${bandOpen.near_km ?? 300}`}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
|
||||
import {
|
||||
ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations,
|
||||
GetWsjtHighlight, SetWsjtHighlight, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||
GetWsjtHighlight, SetWsjtHighlight, GetWsjtHighlightWorked, SetWsjtHighlightWorked, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -160,9 +160,12 @@ type Props = { onError: (msg: string) => void };
|
||||
|
||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
const [highlightOn, setHighlightOn] = useState(false);
|
||||
const [hlWorked, setHlWorked] = useState(false);
|
||||
const [followMode, setFollowMode] = useState(true);
|
||||
useEffect(() => {
|
||||
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
|
||||
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||
GetWsjtHighlightWorked().then((v) => setHlWorked(!!v)).catch(() => {});
|
||||
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
|
||||
}, []);
|
||||
const { t } = useI18n();
|
||||
@@ -246,6 +249,18 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
{/* Nested under the switch above: the same feature, and meaningless
|
||||
while that one is off. */}
|
||||
{highlightOn && (
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl pl-6">
|
||||
<Checkbox checked={hlWorked}
|
||||
onCheckedChange={(c) => { setHlWorked(!!c); void SetWsjtHighlightWorked(!!c); }} />
|
||||
<span>
|
||||
{t('udpp.hlWorked')}
|
||||
<span className="block text-[11px] text-muted-foreground">{t('udpp.hlWorkedHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||||
<Checkbox checked={followMode}
|
||||
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// The design is DXHunter's — the layout, the badges, the wording — repainted in
|
||||
// the app's theme tokens rather than its hard-coded slate/pink.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search } from 'lucide-react';
|
||||
import { Eye, Plus, Trash2, Bell, BellOff, Trophy, Search, Check, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -235,8 +235,16 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
}
|
||||
};
|
||||
|
||||
// Three decimals, and no trailing zeros beyond them: 7.056 rather than
|
||||
// 7.0560, 14.0745 rather than 14.074500. DXHunter's own rule, and the one an
|
||||
// operator reads a cluster line with.
|
||||
const fmtMHz = (hz: number) => {
|
||||
const [int, dec] = (hz / 1e6).toFixed(6).split('.');
|
||||
return int + '.' + dec.slice(0, 3) + dec.slice(3).replace(/0+$/, '');
|
||||
};
|
||||
|
||||
const chip = (color: string, text: string, extra?: string) => (
|
||||
<span className={cn('px-1.5 py-0.5 rounded text-[10px] font-bold border', extra)}
|
||||
<span className={cn('px-1.5 py-0.5 rounded text-[11px] font-semibold border', extra)}
|
||||
style={{ color, borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, background: `color-mix(in srgb, ${color} 12%, transparent)` }}>
|
||||
{text}
|
||||
</span>
|
||||
@@ -332,14 +340,18 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
|
||||
return (
|
||||
<div key={e.callsign}
|
||||
className={cn('rounded-lg border bg-card/70 p-3 transition-colors hover:bg-accent/20',
|
||||
className={cn('rounded border bg-card/70 p-3 transition-colors hover:bg-accent/20',
|
||||
needed > 0 ? 'border-warning/40' : 'border-border/70',
|
||||
e.isContest && 'border-l-4 border-l-warning')}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-lg font-bold font-mono" style={{ color: '#f472b6' }}>{e.callsign}</span>
|
||||
{/* Proportional, not monospaced: DXHunter sets this one in the
|
||||
interface font and the difference is the first thing an
|
||||
operator notices with the two windows side by side. There is
|
||||
nothing to align here — it is a heading, not a column. */}
|
||||
<span className="text-lg font-bold" style={{ color: '#f472b6' }}>{e.callsign}</span>
|
||||
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
|
||||
{e.isContest && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border"
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border"
|
||||
title={t('wl.contestHint')}>
|
||||
<Trophy className="size-3" /> {t('wl.contest')}
|
||||
</span>
|
||||
@@ -349,16 +361,16 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
|
||||
{e.clubLogLiveStream && (
|
||||
<a href="#" onClick={(ev) => { ev.preventDefault(); void OpenExternalURL(`https://clublog.org/livestream/${e.callsign.replace('*', '')}`); }}
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
|
||||
className="px-1.5 py-0.5 rounded text-[11px] font-semibold border text-info border-info/40 bg-info/10 hover:bg-info/20">Live</a>
|
||||
)}
|
||||
{list.length > 0 && (needed > 0
|
||||
? chip('var(--warning)', e.isContest ? t('wl.nToday', { n: needed }) : t('wl.nNeeded', { n: needed }))
|
||||
: chip('var(--success)', e.isContest ? t('wl.workedToday') : t('wl.allWorked')))}
|
||||
{e.lastSeenStr && e.lastSeenStr !== 'Never' && (
|
||||
<span className="text-[11px] text-muted-foreground">· {e.lastSeenStr}</span>
|
||||
<span className="text-[11px] text-muted-foreground">• {e.lastSeenStr}</span>
|
||||
)}
|
||||
{e.spotCount > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground/70">· {t('wl.totalSpots', { n: e.spotCount })}</span>
|
||||
<span className="text-[11px] text-muted-foreground/70">• {t('wl.totalSpots', { n: e.spotCount })}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button type="button" title={t('wl.toggleContest')}
|
||||
@@ -390,14 +402,20 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
onDoubleClick={() => onSpotClick?.(s)}
|
||||
title={t('wl.spotTip')}
|
||||
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
|
||||
!done && 'border-l-[3px] border-warning')}>
|
||||
{done && <span className='font-bold shrink-0 text-success'>✓</span>}
|
||||
!done && 'border-l-2 border-warning')}>
|
||||
{/* Worked or wanted, said with a symbol at the head of
|
||||
the line as well as with the stripe down its side —
|
||||
the same two marks DXHunter uses, and the one an eye
|
||||
finds first when a card holds ten rows. */}
|
||||
{done
|
||||
? <Check className="size-4 shrink-0 text-success" />
|
||||
: <AlertTriangle className="size-4 shrink-0 text-warning" />}
|
||||
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
|
||||
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||||
<span className="font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
|
||||
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
|
||||
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
|
||||
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
|
||||
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{(s.freq_hz / 1e6).toFixed(4)}</span>
|
||||
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{fmtMHz(s.freq_hz)}</span>
|
||||
{badge && chip(badge.color, badge.label)}
|
||||
<div className="flex-1" />
|
||||
{done
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
// Auto-call: let OpsLog answer a decode without the operator clicking it.
|
||||
//
|
||||
// WITHDRAWN FROM THE INTERFACE, because DXHunter already does it.
|
||||
//
|
||||
// Two programs from the same shack deciding on their own to answer the same
|
||||
// decode is worse than either doing it alone: they cannot see each other, so
|
||||
// they key over one another, and afterwards there is no telling which of them
|
||||
// called. The duplicate is the one to remove, and DXHunter is where this lives.
|
||||
//
|
||||
// There is no switch in Preferences and no button on the decodes toolbar, and
|
||||
// App.tsx returns before the loop can run. The file is kept whole: the rules
|
||||
// below are the delicate part, argued over and tested, and rewriting them from
|
||||
// memory later would be worse than leaving them here. Reinstating the feature
|
||||
// means restoring all three — the settings page, the button, and the guard.
|
||||
//
|
||||
// This KEYS THE TRANSMITTER on its own, which is why the rules here are written
|
||||
// as a series of refusals rather than a search for a reason to call. Everything
|
||||
// below has to be true; anything unknown means no.
|
||||
//
|
||||
// The decision is made here, in one pure function, precisely because it is the
|
||||
// dangerous part: it can be read, argued with and tested without a radio.
|
||||
|
||||
export type AutoCallCriteria = {
|
||||
dxcc: boolean; // entity never worked
|
||||
bandmode: boolean; // entity worked, but neither this band nor this mode
|
||||
band: boolean; // entity never worked on this band
|
||||
mode: boolean; // entity never worked in this mode
|
||||
slot: boolean; // band and mode each worked, never together
|
||||
grid: boolean; // square wanted under the grid scope
|
||||
county: boolean; // US county never worked
|
||||
pota: boolean; // park never worked
|
||||
// No SOTA here, though the shape invites it: a decode carries no summit
|
||||
// reference and the backend publishes no "new summit" flag, so a criterion
|
||||
// for it could never be true. It WAS declared, translated and impossible to
|
||||
// tick — a field that lies about what the feature can do.
|
||||
pfx: boolean; // CQ WPX prefix never worked
|
||||
};
|
||||
|
||||
export type AutoCallSettings = {
|
||||
enabled: boolean;
|
||||
criteria: AutoCallCriteria;
|
||||
// Callsigns to answer on sight, wildcards allowed (4S7*, */P). Each is still
|
||||
// subject to `watchCriteria` — "call TM0HQ, but only if it is a new band" is
|
||||
// the request, not "call it every time it appears".
|
||||
watch: string[];
|
||||
// Empty means call a watched callsign whenever it is not already worked.
|
||||
watchCriteria: AutoCallCriteria;
|
||||
// Seconds to ignore a callsign after calling it, so a station that keeps
|
||||
// sending CQ is not re-answered every slot while the QSO is in progress.
|
||||
cooldownSec: number;
|
||||
};
|
||||
|
||||
export const emptyCriteria: AutoCallCriteria = {
|
||||
dxcc: false, bandmode: false, band: false, mode: false, slot: false,
|
||||
grid: false, county: false, pota: false, pfx: false,
|
||||
};
|
||||
|
||||
export const defaultAutoCall: AutoCallSettings = {
|
||||
// OFF, and it stays off until asked for. Unattended transmit is not something
|
||||
// to inherit from an upgrade.
|
||||
enabled: false,
|
||||
criteria: { ...emptyCriteria },
|
||||
watch: [],
|
||||
watchCriteria: { ...emptyCriteria },
|
||||
cooldownSec: 120,
|
||||
};
|
||||
|
||||
const AC_KEY = 'opslog.autoCall';
|
||||
|
||||
export function loadAutoCall(): AutoCallSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(AC_KEY);
|
||||
if (!raw) return { ...defaultAutoCall };
|
||||
const v = JSON.parse(raw);
|
||||
const out: AutoCallSettings = {
|
||||
...defaultAutoCall,
|
||||
...v,
|
||||
criteria: { ...emptyCriteria, ...(v?.criteria ?? {}) },
|
||||
watchCriteria: { ...emptyCriteria, ...(v?.watchCriteria ?? {}) },
|
||||
watch: Array.isArray(v?.watch) ? v.watch : [],
|
||||
};
|
||||
// DISARMED ON SIGHT, and written back disabled.
|
||||
//
|
||||
// The runtime guard in App.tsx stops this build from calling anyone, but it
|
||||
// leaves "enabled": true sitting in storage, where any build without the
|
||||
// guard — an older one an operator reinstalls, a machine that upgrades
|
||||
// later — reads it and keys the transmitter for a feature with no switch
|
||||
// left to turn off. A withdrawn feature that keys a radio has to be
|
||||
// disarmed where it is REMEMBERED, not only where it runs.
|
||||
if (out.enabled) {
|
||||
out.enabled = false;
|
||||
try { localStorage.setItem(AC_KEY, JSON.stringify(out)); } catch { /* private mode: the guard still holds */ }
|
||||
}
|
||||
return out;
|
||||
} catch { return { ...defaultAutoCall }; }
|
||||
}
|
||||
|
||||
export const autoCallKey = AC_KEY;
|
||||
|
||||
// A decode's resolved novelty, the same shape the panel already renders from.
|
||||
export type DecodeStatus = {
|
||||
status?: string;
|
||||
worked_call?: boolean;
|
||||
new_grid?: boolean;
|
||||
grid_state?: string;
|
||||
new_county?: boolean;
|
||||
new_pota?: boolean;
|
||||
new_pfx?: boolean;
|
||||
};
|
||||
|
||||
// matchesWildcard is the same rule the alert filters use: * is any run, ? is one.
|
||||
export function matchesWildcard(pattern: string, call: string): boolean {
|
||||
const p = pattern.trim().toUpperCase();
|
||||
const c = call.trim().toUpperCase();
|
||||
if (!p) return false;
|
||||
const re = new RegExp('^' + p.split('').map((ch) => (
|
||||
ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
)).join('') + '$');
|
||||
return re.test(c);
|
||||
}
|
||||
|
||||
// anyCriterion is false for an all-off set, which is what makes "watch this
|
||||
// callsign, no conditions" expressible.
|
||||
function anyCriterion(c: AutoCallCriteria): boolean {
|
||||
return Object.values(c).some(Boolean);
|
||||
}
|
||||
|
||||
// meets reports whether a decode satisfies at least one ticked criterion.
|
||||
function meets(c: AutoCallCriteria, e: DecodeStatus): boolean {
|
||||
if (c.dxcc && e.status === 'new') return true;
|
||||
// Each status is exclusive, so a station that is new on both counts matches
|
||||
// ONLY this criterion — ticking "new band" alone would not catch it, which is
|
||||
// the wrong way round: it is the better catch of the two.
|
||||
if (c.bandmode && e.status === 'new-band-mode') return true;
|
||||
if (c.band && e.status === 'new-band') return true;
|
||||
if (c.mode && e.status === 'new-mode') return true;
|
||||
if (c.slot && e.status === 'new-slot') return true;
|
||||
// A square that is merely UNCONFIRMED is not called: the QSO is already made,
|
||||
// and calling again would work a duplicate to chase a QSL.
|
||||
if (c.grid && e.new_grid && e.grid_state !== 'unconf') return true;
|
||||
if (c.county && e.new_county) return true;
|
||||
if (c.pota && e.new_pota) return true;
|
||||
if (c.pfx && e.new_pfx) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export type AutoCallDecode = {
|
||||
call: string;
|
||||
cq?: boolean;
|
||||
msg?: string;
|
||||
instance?: string;
|
||||
};
|
||||
|
||||
// shouldAutoCall decides whether to answer one decode. The reason is returned
|
||||
// for the log: an automatic transmission with no record of WHY is the thing an
|
||||
// operator cannot argue with after the fact.
|
||||
export function shouldAutoCall(
|
||||
s: AutoCallSettings,
|
||||
d: AutoCallDecode,
|
||||
e: DecodeStatus | undefined,
|
||||
opts: {
|
||||
// busy is "some receiver is mid-QSO", NOT "this one is transmitting".
|
||||
//
|
||||
// The distinction is the whole bug it fixes: with two instances the caller
|
||||
// used to test a single global transmit flag, which belonged to whichever
|
||||
// receiver reported last. So while slice A worked a station, slice B looked
|
||||
// idle and auto-call started another QSO on it — and the moment either
|
||||
// finished it chained straight into the next. One station at a time means
|
||||
// one across ALL receivers, not one per receiver.
|
||||
busy: boolean;
|
||||
calledAt: Map<string, number>;
|
||||
now: number;
|
||||
myCall?: string;
|
||||
},
|
||||
): { call: boolean; reason: string } {
|
||||
const no = (why: string) => ({ call: false, reason: why });
|
||||
if (!s.enabled) return no('off');
|
||||
if (!e) return no('status not resolved yet');
|
||||
const call = (d.call ?? '').trim().toUpperCase();
|
||||
if (!call) return no('no callsign');
|
||||
// Never answer ourselves, however the decode reached us.
|
||||
if (opts.myCall && call === opts.myCall.trim().toUpperCase()) return no('own callsign');
|
||||
// NOT limited to a CQ, deliberately.
|
||||
//
|
||||
// It used to be, on the reasoning that answering a station mid-QSO is calling
|
||||
// over somebody. That reasoning ignored the case the feature exists for: a
|
||||
// DXpedition running a pileup never sends CQ at all — it works caller after
|
||||
// caller — so the rule sat out the one contact auto-call was turned on for. A
|
||||
// new entity on 15 m FT8, decode after decode, and not a single transmission.
|
||||
//
|
||||
// WHEN to transmit is not ours to decide either: the Reply goes to the
|
||||
// decoder, and MSHV starts at once while JTDX waits for a CQ. Two correct
|
||||
// behaviours, both belonging to the program that owns the timing. Here the
|
||||
// question is only whether the station is one the operator wants — the
|
||||
// criteria below answer that, and the cooldown and the busy check keep it from
|
||||
// calling twice.
|
||||
// Not while ANY receiver is mid-QSO — transmitting, or holding a DX call it
|
||||
// has not finished with. Starting a second exchange before the first is done
|
||||
// is what turned this into a machine that called without stopping.
|
||||
if (opts.busy) return no('a QSO is already in progress');
|
||||
const last = opts.calledAt.get(call);
|
||||
if (last !== undefined && opts.now - last < s.cooldownSec * 1000) return no('called recently');
|
||||
|
||||
// The watch list first: an explicitly named station outranks the general
|
||||
// criteria, and may carry conditions of its own.
|
||||
const watched = s.watch.some((p) => matchesWildcard(p, call));
|
||||
if (watched) {
|
||||
if (!anyCriterion(s.watchCriteria)) {
|
||||
// No conditions attached: call it unless it is already worked.
|
||||
return e.worked_call ? no('watched, but already worked') : { call: true, reason: 'watch list' };
|
||||
}
|
||||
return meets(s.watchCriteria, e)
|
||||
? { call: true, reason: 'watch list + criteria' }
|
||||
: no('watched, but no criterion met');
|
||||
}
|
||||
|
||||
if (!anyCriterion(s.criteria)) return no('no criteria ticked');
|
||||
return meets(s.criteria, e)
|
||||
? { call: true, reason: 'criteria' }
|
||||
: no('no criterion met');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// What a decoding program is CALLED, as against what it calls itself.
|
||||
//
|
||||
// Every WSJT-X-family packet carries an "id" naming the sending program, and
|
||||
// OpsLog shows it wherever a receiver has to be told apart from another. Most
|
||||
// of them send the name on the box: "WSJT-X", "JTDX", "MSHV".
|
||||
//
|
||||
// Nexus does not. It announces itself as "Tempo" — the name of the engine
|
||||
// inside it — so an operator running Nexus saw a program on their screen they
|
||||
// have never heard of, and had to work out that it was theirs.
|
||||
//
|
||||
// Only the LABEL is translated. The id stays the routing key everywhere else:
|
||||
// a Reply, a Halt and the auto-call's own bookkeeping are matched against what
|
||||
// the program sent, and renaming that would send them to nobody.
|
||||
const NAMES: Record<string, string> = {
|
||||
TEMPO: 'Nexus',
|
||||
};
|
||||
|
||||
export function decoderName(id?: string): string {
|
||||
const raw = (id ?? '').trim();
|
||||
if (!raw) return '';
|
||||
// Matched on the leading word: some programs append a version or an instance
|
||||
// number ("WSJT-X - 2", "Tempo 1.4"), and the name is the part before it.
|
||||
const head = raw.split(/[\s\-–—]+/)[0].toUpperCase();
|
||||
return NAMES[head] ?? raw;
|
||||
}
|
||||
+90
-16
File diff suppressed because one or more lines are too long
@@ -172,6 +172,50 @@ export function greatCirclePoints(
|
||||
return out;
|
||||
}
|
||||
|
||||
// splitAtAntimeridian cuts a continuous (unwrapped) path into the pieces that
|
||||
// fit on a map showing ONE world, each piece with longitudes back inside ±180.
|
||||
//
|
||||
// greatCirclePoints deliberately lets longitude run past ±180 so the polyline
|
||||
// stays smooth. That is right for a map with repeating world copies, and wrong
|
||||
// for one without: an arc from Australia to South America came out at 190°,
|
||||
// 210°, 250° — drawn into the empty space off the right-hand edge, ending
|
||||
// nowhere, while its own end marker sat correctly on the far left. From VK,
|
||||
// where most paths cross the antimeridian, that was most of the map's arcs.
|
||||
//
|
||||
// Each crossing ends one piece at exactly ±180 and starts the next at the
|
||||
// opposite edge, at the SAME latitude, so the line leaves one side of the map
|
||||
// and re-enters the other at the height it left. Leaflet takes the result as a
|
||||
// multi-polyline, so one path is still one layer.
|
||||
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] {
|
||||
if (pts.length === 0) return [];
|
||||
// Which copy of the world a longitude belongs to: 0 is the map's own.
|
||||
const world = (lon: number) => Math.floor((lon + 180) / 360);
|
||||
const norm = (lon: number) => lon - 360 * world(lon);
|
||||
const out: [number, number][][] = [];
|
||||
let cur: [number, number][] = [];
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const [lat, lon] = pts[i];
|
||||
if (i > 0) {
|
||||
const [pLat, pLon] = pts[i - 1];
|
||||
const wPrev = world(pLon), wCur = world(lon);
|
||||
if (wPrev !== wCur) {
|
||||
const east = wCur > wPrev;
|
||||
// The meridian actually crossed, in unwrapped degrees.
|
||||
const edge = 180 + 360 * Math.min(wPrev, wCur);
|
||||
const f = (edge - pLon) / (lon - pLon);
|
||||
const edgeLat = pLat + f * (lat - pLat);
|
||||
cur.push([edgeLat, east ? 180 : -180]);
|
||||
out.push(cur);
|
||||
cur = [[edgeLat, east ? -180 : 180]];
|
||||
}
|
||||
}
|
||||
cur.push([lat, norm(lon)]);
|
||||
}
|
||||
if (cur.length > 1) out.push(cur);
|
||||
else if (cur.length === 1 && out.length === 0) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
function toRad(d: number): number { return (d * Math.PI) / 180; }
|
||||
function toDeg(r: number): number { return (r * 180) / Math.PI; }
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ import { GetUIPref } from '../../wailsjs/go/main/App';
|
||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||
// travels with the data/ folder like the language).
|
||||
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'light-nordic' | 'sahara'
|
||||
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'high-contrast';
|
||||
| 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'dark-indigo' | 'dark-teal' | 'dark-plum' | 'dxhunter' | 'dxhunter-orange' | 'high-contrast';
|
||||
|
||||
// Selectable, concrete themes (excludes 'auto') in display order: lights first,
|
||||
// then darks, with high-contrast last — it is an accessibility choice, not a
|
||||
// taste one, and listing it among the moods buries it.
|
||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||
'light-warm', 'light-cool', 'light-sage', 'light-nordic', 'sahara',
|
||||
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum',
|
||||
'dim-slate', 'dark-warm', 'dark-graphite', 'dark-indigo', 'dark-teal', 'dark-plum', 'dxhunter', 'dxhunter-orange',
|
||||
'high-contrast',
|
||||
];
|
||||
|
||||
|
||||
+155
-3
@@ -574,8 +574,8 @@
|
||||
"entity confirmed" cell would read as a button. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2c7a52;
|
||||
--mx-dx-conf: #22d3ee;
|
||||
--mx-dx-work: #1b6b7c;
|
||||
--mx-dx-conf: #a78bfa;
|
||||
--mx-dx-work: #5b4a9e;
|
||||
--mx-none: #2c2f4d;
|
||||
|
||||
--scrollbar-thumb: #383c63;
|
||||
@@ -862,6 +862,156 @@
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* ---- Theme 13: DXHunter — its own slate and blue -----------------------
|
||||
Ported so an operator running both side by side does not switch between two
|
||||
colour worlds every time they look up. It is Tailwind's slate scale, which
|
||||
is what DXHunter is built on: page at slate-900, panels at slate-800, rules
|
||||
at slate-700, and blue-500 for the accent — counted across its sources, not
|
||||
guessed from one panel: blue is 132 uses to violet's 25, and the violet is
|
||||
the PSK Reporter panel alone. Active tab, focus border, primary button: all
|
||||
blue-500. The status colours are DXHunter's too — emerald for good, amber
|
||||
for attention, cyan for information, red for trouble — so a green number
|
||||
means the same thing in both windows. */
|
||||
[data-theme="dxhunter"] {
|
||||
--background: #0f172a; /* slate-900 — the page */
|
||||
--foreground: #e2e8f0; /* slate-200 */
|
||||
--card: #1e293b; /* slate-800 — panels lift off the page */
|
||||
--card-foreground: #e2e8f0;
|
||||
--popover: #1e293b;
|
||||
--popover-foreground: #e2e8f0;
|
||||
--primary: #3b82f6; /* blue-500 — active tabs, focus, buttons */
|
||||
--primary-foreground: #f8fafc;
|
||||
--secondary: #273449;
|
||||
--secondary-foreground: #e2e8f0;
|
||||
--muted: #1c2941; /* toolbars / table headers */
|
||||
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
|
||||
--accent: #2c3b54; /* hover / selection tint */
|
||||
--accent-foreground: #cbd5e1;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fef2f2;
|
||||
--destructive-muted: #3a1518;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #334155; /* slate-700 — every rule in DXHunter */
|
||||
--input: #334155;
|
||||
--ring: #60a5fa; /* blue-400 — its focus border */
|
||||
|
||||
--success: #34d399; /* emerald-400 — "online", confirmed */
|
||||
--success-foreground: #04211a;
|
||||
--success-muted: #0e3029;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #17564a;
|
||||
|
||||
--warning: #fbbf24; /* amber-400 */
|
||||
--warning-foreground: #211803;
|
||||
--warning-muted: #33280f;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #4f3e15;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #211e04;
|
||||
--caution-muted: #322d0e;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4b4315;
|
||||
|
||||
--danger: #f87171; /* red-400 — rose is barely used there */
|
||||
--danger-foreground: #250912;
|
||||
--danger-muted: #3a1621;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #5a2735;
|
||||
|
||||
--info: #22d3ee; /* cyan-400 — "heard near you" */
|
||||
--info-foreground: #04212a;
|
||||
--info-muted: #0c2f3b;
|
||||
--info-muted-foreground: #67e8f9;
|
||||
--info-border: #155e6e;
|
||||
|
||||
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
|
||||
as a button — and violet is where DXHunter puts its own second accent. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2c7a52;
|
||||
--mx-dx-conf: #a78bfa;
|
||||
--mx-dx-work: #5b4a9e;
|
||||
--mx-none: #334155;
|
||||
|
||||
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
|
||||
--scrollbar-thumb-hover: #475569;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- Theme 14: DXHunter orange — the same slate, OpsLog's own accent --
|
||||
DXHunter's slate, kept exactly — page, panels, rules, muted text, and its
|
||||
status colours — with OpsLog's orange in place of its blue. For an operator
|
||||
who wants the two windows to sit together without OpsLog losing the accent
|
||||
it is recognised by. The entity ramp goes VIOLET here for the same reason as
|
||||
in the blue version: it must not read as the accent. */
|
||||
[data-theme="dxhunter-orange"] {
|
||||
--background: #0f172a; /* slate-900 — the page */
|
||||
--foreground: #e2e8f0; /* slate-200 */
|
||||
--card: #1e293b; /* slate-800 — panels lift off the page */
|
||||
--card-foreground: #e2e8f0;
|
||||
--popover: #1e293b;
|
||||
--popover-foreground: #e2e8f0;
|
||||
--primary: #f97316; /* orange-500 — OpsLog's own accent */
|
||||
--primary-foreground: #1c0a02;
|
||||
--secondary: #273449;
|
||||
--secondary-foreground: #e2e8f0;
|
||||
--muted: #1c2941; /* toolbars / table headers */
|
||||
--muted-foreground: #94a3b8; /* slate-400 — DXHunter's muted text */
|
||||
--accent: #2c3b54; /* hover / selection tint */
|
||||
--accent-foreground: #cbd5e1;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fef2f2;
|
||||
--destructive-muted: #3a1518;
|
||||
--destructive-muted-foreground: #fca5a5;
|
||||
--border: #334155; /* slate-700 — every rule in DXHunter */
|
||||
--input: #334155;
|
||||
--ring: #fdba74; /* orange-300 focus ring */
|
||||
|
||||
--success: #34d399; /* emerald-400 — "online", confirmed */
|
||||
--success-foreground: #04211a;
|
||||
--success-muted: #0e3029;
|
||||
--success-muted-foreground: #6ee7b7;
|
||||
--success-border: #17564a;
|
||||
|
||||
--warning: #fbbf24; /* amber-400 */
|
||||
--warning-foreground: #211803;
|
||||
--warning-muted: #33280f;
|
||||
--warning-muted-foreground: #fcd34d;
|
||||
--warning-border: #4f3e15;
|
||||
|
||||
--caution: #facc15;
|
||||
--caution-foreground: #211e04;
|
||||
--caution-muted: #322d0e;
|
||||
--caution-muted-foreground: #fde047;
|
||||
--caution-border: #4b4315;
|
||||
|
||||
--danger: #f87171; /* red-400 — rose is barely used there */
|
||||
--danger-foreground: #250912;
|
||||
--danger-muted: #3a1621;
|
||||
--danger-muted-foreground: #fda4af;
|
||||
--danger-border: #5a2735;
|
||||
|
||||
--info: #22d3ee; /* cyan-400 — "heard near you" */
|
||||
--info-foreground: #04212a;
|
||||
--info-muted: #0c2f3b;
|
||||
--info-muted-foreground: #67e8f9;
|
||||
--info-border: #155e6e;
|
||||
|
||||
/* Blue is the primary, so the entity ramp goes VIOLET rather than reading
|
||||
as a button — and violet is where DXHunter puts its own second accent. */
|
||||
--mx-call-conf: #22c55e;
|
||||
--mx-call-work: #2c7a52;
|
||||
--mx-dx-conf: #a78bfa;
|
||||
--mx-dx-work: #5b4a9e;
|
||||
--mx-none: #334155;
|
||||
|
||||
--scrollbar-thumb: #334155; /* DXHunter's own scrollbar */
|
||||
--scrollbar-thumb-hover: #475569;
|
||||
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(226, 232, 240, 0.05);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||
@@ -905,7 +1055,9 @@
|
||||
[data-theme="high-contrast"],
|
||||
[data-theme="dark-indigo"],
|
||||
[data-theme="dark-teal"],
|
||||
[data-theme="dark-plum"] {
|
||||
[data-theme="dark-plum"],
|
||||
[data-theme="dxhunter"],
|
||||
[data-theme="dxhunter-orange"] {
|
||||
--chart-1: #3987e5;
|
||||
--chart-2: #199e70;
|
||||
--chart-3: #c98500;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.27.11';
|
||||
export const APP_VERSION = '0.27.12';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Reference in New Issue
Block a user