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:
|
||||
|
||||
Reference in New Issue
Block a user