feat: panadapter spots that read, a CI-V link that survives, no auto-call
Panadapter spots are now worth reading. The comment carries the spotter, the
entity and the status in DXHunter's own shape — "CQ up 2 [F4BPO] [Franz Josef
Land] [New Slot]" — which needed two things nothing documents: SmartSDR splits
its command line on SPACES, so the words ran together until every space became
non-breaking; and it truncates past ~60 characters, so the cluster's own words
are trimmed first and the three brackets always survive. RBN column padding is
collapsed on the way in, or a preserved run of spaces opened a gap wide enough
to push the rest off screen.
"Already worked" means the CALLSIGN is in the log, not the entity: saying it of
a station never contacted was simply wrong. Each status can also be kept off the
panadapter entirely, and the WSJT-X decode spots obey the same switches — the
palette governs the panadapter, not one of the two things that feed it.
And the radio is no longer hammered: a spot whose frequency, colour and comment
are unchanged is not removed and redrawn. A busy skimmer feed re-spots the same
station every few seconds; one two-minute session sent 2128 adds, 88 of them for
a single callsign, and the display did not move a pixel for any of them.
CI-V, from an IC-7850 that kept killing JTDX: a reply the rig sent to another
controller on the same bus is no longer taken for ours, and a set_ptt, set_freq
or set_mode whose acknowledgement goes missing is verified by reading the rig
back instead of being reported as a failure. WSJT-X and JTDX answer a failed
command with a Rig Control Error and drop the link mid-over — 98 keyings, 6 lost
acknowledgements, 2 dropped connections in one session. The check waits 700 ms,
not the poll's 150: the rig has just failed to answer twice because it was
retuning, and a short probe would fail for the same reason.
Auto-call is withdrawn — it duplicated DXHunter, which already answers decodes,
and two programs deciding that from one shack key over each other. The library
is kept whole and dormant; a guard in App.tsx makes sure a stored preference
cannot key a transmitter whose switch no longer exists.
Also:
- the log rotates while running, not only at startup: the CI-V trace left on
wrote 416 MB and nothing would have stopped it before the disk did. Closing
it now releases the crash file too — the runtime keeps its own duplicate.
- the interface zoom announces itself, with a badge, a click back to 100% and
a View menu; Ctrl+wheel and Ctrl+0 always worked and nothing said so.
- no more elastic bounce, and no swipe-to-navigate out of the app.
- Edit QSO: your own TX power and the contacted station's extended locator
were saved and written back with no box to set them.
- FT decodes: continents are a multiple choice; a compound MSHV message that
answers two stations in one line is recognised as addressed to you.
This commit is contained in:
+78
-13
@@ -1284,11 +1284,22 @@ export default function App() {
|
||||
// by z, so it reflows to fill that larger box and then scales back to exactly the
|
||||
// window (more content visible when zoomed out, like a real browser zoom).
|
||||
const appRootRef = useRef<HTMLDivElement>(null);
|
||||
// The current factor, shown briefly whenever it changes.
|
||||
//
|
||||
// Ctrl+wheel and Ctrl+0 have worked from the start, and NOTHING said so:
|
||||
// no menu entry, no readout. An operator who zoomed by accident — easy, the
|
||||
// wheel is over a grid and Ctrl is already down for something else — saw the
|
||||
// interface change size with no explanation and no way back. The number is
|
||||
// the fix; the menu entry makes it findable without reading a manual.
|
||||
const [uiZoom, setUiZoom] = useState(1);
|
||||
const [zoomShown, setZoomShown] = useState(false);
|
||||
const zoomHideRef = useRef(0);
|
||||
const setZoomRef = useRef<(z: number) => void>(() => {});
|
||||
useEffect(() => {
|
||||
const KEY = 'opslog.uiZoom';
|
||||
let z = parseFloat(localStorage.getItem(KEY) || '1');
|
||||
if (!Number.isFinite(z) || z <= 0) z = 1;
|
||||
const apply = () => {
|
||||
const apply = (announce = false) => {
|
||||
const el = appRootRef.current;
|
||||
if (!el) return;
|
||||
el.style.transformOrigin = '0 0';
|
||||
@@ -1296,17 +1307,36 @@ export default function App() {
|
||||
el.style.width = `${100 / z}vw`;
|
||||
el.style.height = `${100 / z}vh`;
|
||||
document.documentElement.setAttribute('data-uizoom', String(z));
|
||||
setUiZoom(z);
|
||||
// Only on a CHANGE: showing it at startup would put a badge over the log
|
||||
// every launch, for a number that is almost always 100 %.
|
||||
if (announce) {
|
||||
setZoomShown(true);
|
||||
window.clearTimeout(zoomHideRef.current);
|
||||
zoomHideRef.current = window.setTimeout(() => setZoomShown(false), 1600);
|
||||
}
|
||||
};
|
||||
apply();
|
||||
// The menu and the badge set the factor through here, so there is one
|
||||
// implementation of "zoom to z" and not three.
|
||||
setZoomRef.current = (next: number) => {
|
||||
z = Math.min(2.5, Math.max(0.5, Math.round(next * 10) / 10));
|
||||
localStorage.setItem(KEY, String(z));
|
||||
apply(true);
|
||||
};
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey && !e.metaKey) return; // plain wheel is left alone (scroll / freq nudge)
|
||||
e.preventDefault();
|
||||
z = Math.min(2.5, Math.max(0.5, Math.round((z + (e.deltaY < 0 ? 0.1 : -0.1)) * 10) / 10));
|
||||
localStorage.setItem(KEY, String(z));
|
||||
apply();
|
||||
apply(true);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '0')) { e.preventDefault(); z = 1; localStorage.setItem(KEY, '1'); apply(); }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '0')) { e.preventDefault(); z = 1; localStorage.setItem(KEY, '1'); apply(true); }
|
||||
// Ctrl+plus / Ctrl+minus, the shortcuts every other application uses.
|
||||
// '=' as well as '+': on most layouts the plus is the shifted '='.
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=')) { e.preventDefault(); setZoomRef.current(z + 0.1); }
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === '-')) { e.preventDefault(); setZoomRef.current(z - 0.1); }
|
||||
};
|
||||
window.addEventListener('wheel', onWheel, { passive: false });
|
||||
window.addEventListener('keydown', onKey);
|
||||
@@ -2274,6 +2304,9 @@ export default function App() {
|
||||
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
|
||||
@@ -2306,6 +2339,15 @@ export default function App() {
|
||||
// backstop below.
|
||||
const lastTxAtRef = useRef<Map<string, number>>(new Map());
|
||||
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
|
||||
@@ -2663,8 +2705,17 @@ export default function App() {
|
||||
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
|
||||
useEffect(() => {
|
||||
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
|
||||
return () => { off(); };
|
||||
}, [showToast]);
|
||||
// A QSO logged by an external app announces itself: nothing else on screen
|
||||
// says it happened when the operator is watching the decodes rather than the
|
||||
// entry form.
|
||||
const offExt = EventsOn('qso:logged_external', (p: any) => {
|
||||
const call = String(p?.call ?? '').trim();
|
||||
if (!call) return;
|
||||
const bits = [p?.band, p?.mode].filter(Boolean).join(' · ');
|
||||
showToast(`✅ ${t('toast.qsoLogged')} — ${call}${bits ? ` · ${bits}` : ''}`);
|
||||
});
|
||||
return () => { off(); offExt(); };
|
||||
}, [showToast, t]);
|
||||
|
||||
// Band openings — sporadic E on 6/4/2 m, detected from the spot stream.
|
||||
//
|
||||
@@ -4668,6 +4719,10 @@ export default function App() {
|
||||
{ type: 'item', label: t('edit.prefs'), action: 'edit.prefs' },
|
||||
]},
|
||||
{ name: 'view', label: t('menu.view'), items: [
|
||||
{ type: 'item', label: t('view.zoomIn'), action: 'view.zoomin', shortcut: 'Ctrl++' },
|
||||
{ type: 'item', label: t('view.zoomOut'), action: 'view.zoomout', shortcut: 'Ctrl+-' },
|
||||
{ type: 'item', label: t('view.zoomReset'), action: 'view.zoomreset', shortcut: 'Ctrl+0' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
|
||||
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
|
||||
]},
|
||||
@@ -4716,6 +4771,9 @@ export default function App() {
|
||||
case 'file.exportCabrillo': exportCabrillo(); break;
|
||||
case 'file.deleteall': setShowDeleteAll(true); break;
|
||||
case 'file.exit': QuitApp(); break;
|
||||
case 'view.zoomin': setZoomRef.current(uiZoom + 0.1); break;
|
||||
case 'view.zoomout': setZoomRef.current(uiZoom - 0.1); break;
|
||||
case 'view.zoomreset': setZoomRef.current(1); break;
|
||||
case 'view.refresh': refresh(); break;
|
||||
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||
@@ -5812,14 +5870,6 @@ export default function App() {
|
||||
txMsgs={txMsgs}
|
||||
txState={txState}
|
||||
txStates={txStates}
|
||||
autoCallOn={autoCall.enabled}
|
||||
// Written through the same key Preferences uses, so the two can never
|
||||
// disagree about whether the machine is armed.
|
||||
onToggleAutoCall={() => setAutoCall((prev) => {
|
||||
const next = { ...prev, enabled: !prev.enabled };
|
||||
writeUiPref(autoCallKey, JSON.stringify(next));
|
||||
return next;
|
||||
})}
|
||||
spotStatus={spotStatus as any}
|
||||
myCall={station.callsign}
|
||||
// A click ANSWERS the station: it hands the decode back to WSJT-X/MSHV as
|
||||
@@ -6668,6 +6718,21 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The zoom factor, while it is changing.
|
||||
Centred at the top, out of the way of the entry form and the toasts,
|
||||
and clickable: the number is only half an answer if the way back to
|
||||
100 % is another thing to know. */}
|
||||
{zoomShown && uiZoom !== 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomRef.current(1)}
|
||||
title={t('view.zoomResetTip')}
|
||||
className="fixed top-2 left-1/2 -translate-x-1/2 z-[130] rounded-full border border-border bg-card/95 px-3 py-1 text-xs font-mono shadow-lg hover:bg-accent"
|
||||
>
|
||||
{Math.round(uiZoom * 100)}% · {t('view.zoomReset')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
||||
success toast; both auto-dismiss. */}
|
||||
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
||||
|
||||
@@ -531,7 +531,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
});
|
||||
const [bandSel, setBandSel] = usePersisted('bandSel', '');
|
||||
const [modeSel, setModeSel] = usePersisted('modeSel', '');
|
||||
const [contSel, setContSel] = usePersisted('contSel', '');
|
||||
// Continents are a MULTI-selection, not one at a time: the useful filter is
|
||||
// "everything except NA and EU", which a single-choice dropdown cannot say —
|
||||
// it can only name one continent to keep. Stored as a list (a Set does not
|
||||
// survive JSON), empty meaning no filter at all.
|
||||
const [contList, setContList] = usePersisted<string[]>('contList', []);
|
||||
const contSel = useMemo(() => new Set(contList), [contList]);
|
||||
const toggleCont = (c: string) =>
|
||||
setContList(contSel.has(c) ? contList.filter((x) => x !== c) : [...contList, c].sort());
|
||||
const [minSnr, setMinSnr] = usePersisted('minSnr', '');
|
||||
const [search, setSearch] = usePersisted('search', '');
|
||||
|
||||
@@ -554,8 +561,21 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
const calling = (txState?.dx_call ?? '').toUpperCase();
|
||||
const answersMe = (msg?: string): boolean => {
|
||||
if (!me || !msg) return false;
|
||||
const first = msg.trim().split(/\s+/)[0]?.replace(/[<>]/g, '').toUpperCase();
|
||||
return !!first && first === me;
|
||||
// EVERY segment, not just the first.
|
||||
//
|
||||
// MSHV answers two stations in one transmission and sends them as one line:
|
||||
//
|
||||
// DM8BJF RR73; F4BPO <RI1FJL> +14
|
||||
//
|
||||
// The second half is addressed to F4BPO, and reading only the start of the
|
||||
// line missed it — the operator being answered saw no badge at all, which is
|
||||
// the one moment this badge exists for. Each ';' segment is its own message
|
||||
// and names its own recipient first.
|
||||
for (const part of msg.split(';')) {
|
||||
const first = part.trim().split(/\s+/)[0]?.replace(/[<>]/g, '').toUpperCase();
|
||||
if (first && first === me) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// The choices are built from what is actually on the feed, and a selector with
|
||||
@@ -576,6 +596,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [decodes, spotStatus]);
|
||||
|
||||
// The chips are the continents on the feed — a selector with nothing to choose
|
||||
// is furniture — PLUS anything currently selected. Without that second half a
|
||||
// filter can strand itself: pick AF, the last African station stops decoding,
|
||||
// and the list empties with no chip left to switch it back off.
|
||||
const contChips = useMemo(
|
||||
() => [...new Set([...conts, ...contList])].sort(),
|
||||
[conts, contList],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toUpperCase();
|
||||
const floor = minSnr.trim() === '' ? null : parseInt(minSnr, 10);
|
||||
@@ -594,7 +623,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
for (const c of cats) if (have.has(c)) { hit = true; break; }
|
||||
if (!hit) return false;
|
||||
}
|
||||
if (contSel && e?.continent !== contSel) return false;
|
||||
if (contSel.size > 0 && !(e?.continent && contSel.has(e.continent))) return false;
|
||||
if (q && !(d.call.includes(q) || (d.grid ?? '').toUpperCase().includes(q) || (d.msg ?? '').toUpperCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
@@ -627,11 +656,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
|
||||
const resetFilters = () => {
|
||||
setCqOnly(false); setLotwOnly(false); setBandSel('');
|
||||
setModeSel(''); setContSel(''); setMinSnr(''); setSearch('');
|
||||
setModeSel(''); setContList([]); setMinSnr(''); setSearch('');
|
||||
setCats(new Set());
|
||||
try { localStorage.setItem(CAT_KEY, '[]'); } catch { /* not worth failing over */ }
|
||||
};
|
||||
const anyFilter = cqOnly || lotwOnly || cats.size > 0 || !!bandSel || !!modeSel || !!contSel || !!minSnr || !!search.trim();
|
||||
const anyFilter = cqOnly || lotwOnly || cats.size > 0 || !!bandSel || !!modeSel || contSel.size > 0 || !!minSnr || !!search.trim();
|
||||
|
||||
const sel = 'h-8 rounded-lg border border-border bg-background px-2 text-sm';
|
||||
const chip = (on: boolean, tone = 'primary') => cn(
|
||||
@@ -700,11 +729,28 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
{modes.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{conts.length > 1 && (
|
||||
<select className={sel} value={contSel} onChange={(e) => setContSel(e.target.value)}>
|
||||
<option value="">{t('dec.allConts')}</option>
|
||||
{conts.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
{/* Toggles, like the category badges above — the same gesture for the
|
||||
same kind of question. None lit is every continent, which is what an
|
||||
empty filter has always meant here. */}
|
||||
{contChips.length > 1 && (
|
||||
<span className="flex items-center gap-1 pl-1 border-l border-border/60 ml-1" title={t('dec.contsHint')}>
|
||||
{contChips.map((c) => {
|
||||
const on = contSel.has(c);
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => toggleCont(c)}
|
||||
className={cn(
|
||||
'rounded border px-1.5 py-0.5 text-[11px] font-bold uppercase tracking-wide transition-all',
|
||||
on ? 'border-primary text-primary' : 'border-border text-muted-foreground opacity-50 hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
@@ -765,21 +811,6 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
|
||||
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. */}
|
||||
{onToggleAutoCall && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleAutoCall}
|
||||
title={t('dec.autoCallTip')}
|
||||
className={cn('h-8 px-2.5 rounded-lg text-sm inline-flex items-center gap-1.5 border',
|
||||
// Lit when it is armed, because what matters at a glance is not
|
||||
// "where is the switch" but "is this thing about to transmit".
|
||||
autoCallOn
|
||||
? 'border-warning bg-warning text-warning-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground')}
|
||||
>
|
||||
<Bot className="size-3.5" /> {t('dec.autoCall')}
|
||||
</button>
|
||||
)}
|
||||
{onHalt && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -626,6 +626,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col flex-1"><Label>{t('qedit.grid')}</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col w-24"><Label>{t('qedit.gridExt')}</Label><Input title={t('qedit.gridExtTip')} value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} className="font-mono uppercase" /></div>
|
||||
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
|
||||
</div>
|
||||
<div><Label>{t('qedit.comment')}</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)}
|
||||
@@ -867,7 +868,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<F label={t('qedit.stationCallsign')} span={3}><Input value={draft.station_callsign ?? ''} onChange={(e) => set('station_callsign', e.target.value)} /></F>
|
||||
<F label={t('qedit.operator')} span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
|
||||
<F label={t('qedit.myGrid')}><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
|
||||
<F label={t('qedit.gridExt')}><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||
<F label={t('qedit.myGridExt')}><Input title={t('qedit.gridExtTip')} value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
|
||||
<F label={t('qedit.country')} span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder={t('qedit.country')} onChange={(v) => set('my_country', v)} /></F>
|
||||
<F label={t('qedit.state')}><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
|
||||
<F label={t('qedit.county')}><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
|
||||
@@ -903,6 +904,10 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">{t('qedit.powerWeather')}</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
{/* YOUR power first, then theirs. tx_pwr was saved by this
|
||||
editor but had no field: a contact logged at the wrong
|
||||
power could be read back and never corrected. */}
|
||||
<F label={t('qedit.txPower')}><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.rxPower')}><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.distance')}><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
|
||||
<F label={t('qedit.aIndex')}><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
|
||||
|
||||
@@ -209,7 +209,6 @@ type SectionId =
|
||||
| 'databases'
|
||||
| 'awards'
|
||||
| 'cat'
|
||||
| 'ftx'
|
||||
| 'rotator'
|
||||
| 'winkeyer'
|
||||
| 'antenna'
|
||||
@@ -314,7 +313,6 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
{ kind: 'item', label: t('sec.modes'), id: 'lists-modes' },
|
||||
]},
|
||||
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' },
|
||||
{ kind: 'item', label: t('sec.ftx'), id: 'ftx' },
|
||||
{ kind: 'item', label: t('sec.udp'), id: 'udp' },
|
||||
{ kind: 'item', label: t('sec.adifmon'), id: 'adifmon' },
|
||||
{ kind: 'item', label: t('sec.foldersync'), id: 'foldersync' },
|
||||
@@ -343,7 +341,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
||||
const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
||||
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
|
||||
'external-services': 'sec.external', appearance: 'sec.appearance', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
|
||||
cluster: 'sec.cluster', ftx: 'sec.ftx', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
|
||||
adifmon: 'sec.adifmon',
|
||||
foldersync: 'sec.foldersync',
|
||||
webpublish: 'sec.webpublish',
|
||||
@@ -1801,7 +1799,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// form: waiting for the modal's Save to see a colour on the waterfall would
|
||||
// make picking one a guessing game.
|
||||
type SpotColor = { text: string; bg?: string };
|
||||
const [spotColors, setSpotColors] = useState<{ enabled: boolean; colors: Record<string, SpotColor> }>({ enabled: true, colors: {} });
|
||||
const [spotColors, setSpotColors] = useState<{ enabled: boolean; colors: Record<string, SpotColor & { hide?: boolean }> }>({ enabled: true, colors: {} });
|
||||
useEffect(() => { GetSpotColors().then((c: any) => setSpotColors(c ?? { enabled: true, colors: {} })).catch(() => {}); }, []);
|
||||
const saveSpotColors = (next: { enabled: boolean; colors: Record<string, SpotColor> }) => {
|
||||
setSpotColors(next);
|
||||
@@ -4783,91 +4781,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
setEditingServer(next);
|
||||
}
|
||||
|
||||
// FTx decodes: what OpsLog does on its own with the digital stream.
|
||||
//
|
||||
// Auto-call KEYS THE TRANSMITTER without anyone clicking, so the panel is
|
||||
// deliberately explicit about it: off by default, every criterion opt-in, and
|
||||
// a plain statement of what the machine will do once it is on.
|
||||
function FtxPanel() {
|
||||
const set = (patch: Partial<AutoCallSettings>) => {
|
||||
const next = { ...autoCall, ...patch };
|
||||
setAutoCall(next);
|
||||
writeUiPref(autoCallKey, JSON.stringify(next));
|
||||
};
|
||||
const crit = (which: 'criteria' | 'watchCriteria', k: keyof AutoCallCriteria) => (
|
||||
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!autoCall[which][k]}
|
||||
onCheckedChange={(v) => set({ [which]: { ...autoCall[which], [k]: !!v } } as any)}
|
||||
/>
|
||||
{t(`ftx.c_${k}`)}
|
||||
</label>
|
||||
);
|
||||
const KEYS: (keyof AutoCallCriteria)[] = ['dxcc', 'bandmode', 'band', 'mode', 'slot', 'grid', 'county', 'pota', 'pfx'];
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={t('sec.ftx')} hint={t('ftx.hint')} />
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border p-3 space-y-3">
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={autoCall.enabled} className="mt-0.5"
|
||||
onCheckedChange={(v) => set({ enabled: !!v })} />
|
||||
<span>
|
||||
{t('ftx.enable')}{' '}
|
||||
<span className="text-xs text-muted-foreground">{t('ftx.enableHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{autoCall.enabled && (
|
||||
<div className="pl-6 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-1.5">{t('ftx.callWhen')}</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">{KEYS.map((k) => crit('criteria', k))}</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-1">{t('ftx.watch')}</p>
|
||||
<p className="text-[11px] text-muted-foreground mb-1.5 leading-relaxed">{t('ftx.watchHint')}</p>
|
||||
<Textarea
|
||||
className="h-20 font-mono text-xs"
|
||||
placeholder={"4S7*\nTM0HQ\n*/P"}
|
||||
defaultValue={(autoCall.watch ?? []).join('\n')}
|
||||
key={`w-${(autoCall.watch ?? []).length}`}
|
||||
onBlur={(e) => set({
|
||||
watch: e.target.value.split('\n').map((x) => x.trim().toUpperCase()).filter(Boolean),
|
||||
})}
|
||||
/>
|
||||
{(autoCall.watch ?? []).length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-[11px] text-muted-foreground mb-1">{t('ftx.watchOnlyIf')}</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">{KEYS.map((k) => crit('watchCriteria', k))}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/60 pt-3 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{t('ftx.cooldown')}</span>
|
||||
<Input type="number" min={10} max={3600} className="w-24 h-7 text-xs"
|
||||
defaultValue={autoCall.cooldownSec}
|
||||
key={`cd-${autoCall.cooldownSec}`}
|
||||
onBlur={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v >= 10 && v <= 3600) set({ cooldownSec: v });
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">s</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] rounded border border-warning-border bg-warning-muted text-warning-muted-foreground px-2 py-1.5 leading-relaxed">
|
||||
{t('ftx.warn')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterPanel() {
|
||||
const sorted = [...clusterServers].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
||||
// Written on every keystroke. This panel has no Save button, and a pair of
|
||||
@@ -7233,7 +7146,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
'lists-bands': BandsPanel,
|
||||
'lists-modes': ModesPanel,
|
||||
cluster: ClusterPanel,
|
||||
ftx: FtxPanel,
|
||||
udp: UDPIntegrationsPanelWrapper,
|
||||
// Module-scope components, wrapped so their props can be passed. The nested
|
||||
// panels below go through PanelHost instead — which is what now lets either
|
||||
@@ -7343,7 +7255,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const c = spotColors.colors[k] ?? { text: '', bg: '' };
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-3 px-3 py-1.5">
|
||||
<span className="text-xs flex-1 min-w-0">{t('spotcol.s_' + k)}</span>
|
||||
{/* Sent to the radio at all. First in the row because it
|
||||
decides whether the colours next to it mean anything. */}
|
||||
<Checkbox checked={!c.hide} title={t('spotcol.send')}
|
||||
onCheckedChange={(v) => setSpotColor(k, { hide: !v } as any)} />
|
||||
<span className={cn('text-xs flex-1 min-w-0', c.hide && 'opacity-40 line-through')}>{t('spotcol.s_' + k)}</span>
|
||||
{/* The preview is the point of the row: two colour boxes say
|
||||
nothing about what the pair looks like together. */}
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded"
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
// 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.
|
||||
@@ -154,9 +167,20 @@ export function shouldAutoCall(
|
||||
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');
|
||||
// Only a CQ. Answering a station mid-QSO is both rude and futile — WSJT-X
|
||||
// actions a Reply only for CQ and QRZ anyway.
|
||||
if (!d.cq) return no('not a CQ');
|
||||
// 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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -991,6 +991,18 @@
|
||||
@layer base {
|
||||
* { @apply border-border; }
|
||||
html, body, #root { height: 100%; }
|
||||
/* No rubber-band, and no swipe-to-navigate.
|
||||
|
||||
Chromium lets a scroll that has reached the end pull the content a few
|
||||
pixels and spring back, and a two-finger horizontal swipe means "go back a
|
||||
page". Both are browser behaviours that have no meaning in a logbook: the
|
||||
first makes a table wobble at the bottom of a list, and the second can
|
||||
navigate the WebView away from the app entirely — a blank window and a
|
||||
restart. Neither shows up with a mouse, which is why they went unnoticed;
|
||||
they arrive with a precision touchpad or a touchscreen.
|
||||
|
||||
A no-op wherever the effects don't occur. */
|
||||
html, body { overscroll-behavior: none; }
|
||||
/* Root rem base. Tailwind sizing (text/heights/padding/gaps) is in rem, so
|
||||
this is the single global density knob — shrinks the whole UI uniformly
|
||||
while Leaflet maps (px-based) stay correct. Default browser base is 16px. */
|
||||
@@ -1055,3 +1067,15 @@
|
||||
filter: invert(1) brightness(1.8);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Every scrollable pane keeps its overscroll to itself.
|
||||
|
||||
"contain" (not "none") on the inside: the pane still scrolls normally, it
|
||||
simply stops passing the leftover motion up to its parent — which is how
|
||||
scrolling a grid to its end used to tug the panel behind it. */
|
||||
.overflow-auto,
|
||||
.overflow-y-auto,
|
||||
.overflow-x-auto,
|
||||
.overflow-scroll {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user