This commit is contained in:
2026-06-07 21:44:49 +02:00
parent 3dd9620cca
commit 6542504a4b
14 changed files with 585 additions and 139 deletions
+96 -1
View File
@@ -23,18 +23,23 @@
"ag-grid-react": "^35.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-geo": "^3.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^3.6.0"
"tailwind-merge": "^3.6.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/d3-geo": "^3.1.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.9.1",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
@@ -2579,6 +2584,16 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2641,6 +2656,27 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/topojson-client": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*",
"@types/topojson-specification": "*"
}
},
"node_modules/@types/topojson-specification": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2792,6 +2828,12 @@
"node": ">=6"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2806,6 +2848,30 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2972,6 +3038,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -3659,6 +3734,20 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3845,6 +3934,12 @@
}
}
},
"node_modules/world-atlas": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+6 -1
View File
@@ -24,18 +24,23 @@
"ag-grid-react": "^35.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-geo": "^3.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^3.6.0"
"tailwind-merge": "^3.6.0",
"topojson-client": "^3.1.0",
"world-atlas": "^2.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/d3-geo": "^3.1.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.9.1",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
+1 -1
View File
@@ -1 +1 @@
c98874941451e4e6ffa48f22c1d764e7
f9b41e192918fa2511f68cd1b361fcd3
+146 -97
View File
@@ -53,6 +53,8 @@ import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
import { Button } from '@/components/ui/button';
@@ -416,8 +418,6 @@ export default function App() {
return () => { cancelled = true; };
}, [band]);
const prefix = useMemo(() => computePrefix(callsign), [callsign]);
// Bearing/distance from operator's home grid to the remote station —
// shown live in the entry strip (SP azimuth) and Info tab (LP + dist).
// === Logbook list ===
const [qsos, setQsos] = useState<QSO[]>([]);
@@ -444,13 +444,21 @@ export default function App() {
// Elapsed recording time (seconds) shown next to the red dot, ticking once a
// second while a recording is in progress.
const [recSeconds, setRecSeconds] = useState(0);
// Bumped on every (re)start so the timer resets even when `recording` was
// already true (jumping spot→spot keeps recording=true but starts a fresh take).
const [recTick, setRecTick] = useState(0);
useEffect(() => {
if (!recording) { setRecSeconds(0); return; }
const start = Date.now();
setRecSeconds(0);
const id = window.setInterval(() => setRecSeconds(Math.floor((Date.now() - start) / 1000)), 1000);
return () => window.clearInterval(id);
}, [recording]);
}, [recording, recTick]);
// restartRecordingForNewTarget (re)starts the take for a new programmatic
// target (clicked spot / external app via UDP) and resets the elapsed timer.
const restartRecordingForNewTarget = () => {
QSOAudioRestart().then((active) => { setRecording(active); setRecTick((t) => t + 1); }).catch(() => {});
};
const [saving, setSaving] = useState(false);
const [filterCallsign, setFilterCallsign] = useState('');
// Advanced filter builder (replaces the old band/mode dropdowns).
@@ -473,7 +481,7 @@ export default function App() {
const raw = Number(localStorage.getItem('hamlog.qsoLimit') ?? '500');
return Number.isFinite(raw) && raw > 0 ? raw : 500;
});
useEffect(() => { localStorage.setItem('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
useEffect(() => { writeUiPref('hamlog.qsoLimit', String(qsoLimit)); }, [qsoLimit]);
// === DX Cluster live state ===
type ClusterSpot = {
@@ -591,7 +599,7 @@ export default function App() {
const toggleBandMapSide = useCallback(() => {
setBandMapSide((s) => {
const next = s === 'right' ? 'left' : 'right';
localStorage.setItem('bandmap.side', next);
writeUiPref('bandmap.side', next);
return next;
});
}, []);
@@ -607,6 +615,8 @@ export default function App() {
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [showSettings, setShowSettings] = useState(false);
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
// Optional deep-link: which Preferences section to open. Cleared on
// close so the next plain "Preferences" launch reverts to default.
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
@@ -654,11 +664,6 @@ export default function App() {
// tell whether an incoming DX call actually changed anything.
const callsignValRef = useRef('');
useEffect(() => { callsignValRef.current = callsign; }, [callsign]);
// True while the operator is typing in the Call field. A call change that
// arrives while it's NOT focused is programmatic (clicked spot / external app
// via UDP) → we (re)start the recording immediately; typed changes wait for
// blur so we don't restart on every keystroke.
const callFocusedRef = useRef(false);
// When the entered callsign turns out to be worked-before, jump to the
// Worked-before tab so the history is front-and-centre. Only once per call,
@@ -681,6 +686,36 @@ export default function App() {
});
myCallRef.current = (station.callsign || '').toUpperCase();
// Bearing/distance from operator's grid to the DX — used by the entry-strip
// azimuth, the Info tab, and the rotor compass. Grid-to-grid when both known;
// else fall back to the DX lat/lon (cty.dat-only entities carry no grid).
const dxPath = useMemo(() => {
const byGrid = pathBetween(station.my_grid, grid);
if (byGrid) return byGrid;
const myLL = gridToLatLon(station.my_grid);
if (myLL && details.lat != null && details.lon != null) {
return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon });
}
return null;
}, [station.my_grid, grid, details.lat, details.lon]);
// Effective antenna heading(s): the rotor azimuth, transformed by the
// Ultrabeam pattern when one is active — reversed (180°) points opposite,
// bidirectional radiates both ways, normal is the heading itself.
const beamHeadings = useMemo<number[]>(() => {
if (!(rotatorHeading.enabled && rotatorHeading.ok)) return [];
const base = ((rotatorHeading.azimuth % 360) + 360) % 360;
if (ubStatus.enabled && ubStatus.connected) {
if (ubStatus.direction === 1) return [(base + 180) % 360];
if (ubStatus.direction === 2) return [base, (base + 180) % 360];
}
return [base];
}, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
// picked award references to the QSO field/extras each award actually reads.
const awardFieldRef = useRef<Record<string, string>>({});
@@ -966,10 +1001,18 @@ export default function App() {
if (!call) return;
// Don't clobber what the user is currently typing — only update
// when the entry field is empty or matches a previous broadcast.
const changed = call.toUpperCase() !== callsignValRef.current.trim().toUpperCase();
onCallsignInput(call);
// External app jumped to a new station (DXHunter/WSJT/MSHV click): start a
// fresh recording for the new target instead of continuing the old take.
if (changed) restartRecordingForNewTarget();
});
const unsubRC = EventsOn('udp:remote_call', (call: string) => {
if (call) onCallsignInput(String(call).trim());
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
const call = String(raw ?? '').trim();
if (!call) return;
const changed = call.toUpperCase() !== callsignValRef.current.trim().toUpperCase();
onCallsignInput(call);
if (changed) restartRecordingForNewTarget();
});
const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
@@ -1118,8 +1161,12 @@ export default function App() {
const freqHz = freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined;
const rxFreqHz = rxFreqMhz.trim() ? Math.round(parseFloat(rxFreqMhz) * 1_000_000) : undefined;
const now = new Date();
const start = qsoStartedAt ?? now;
const end = (locks.end && qsoEndedAt) ? qsoEndedAt : now;
// Option: log TIME_ON = TIME_OFF (the moment the QSO completes). Useful
// when you call a station for a long time — otherwise TIME_ON is frozen at
// when you first entered the call (minutes early) and won't match LoTW.
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
const start = (startEqualsEnd && !locks.start) ? end : (qsoStartedAt ?? now);
const payload: any = {
callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(),
@@ -1406,19 +1453,17 @@ export default function App() {
// start is locked: the user is back-entering a past QSO and set a
// specific time manually.
setQsoStartedAt(new Date());
// Begin the recording here too: a fast CW workflow (type the call then
// hit Enter to log, exchanging with the WinKeyer) never blurs the call
// field, so the blur-based start was missed. No-op if the recorder is off
// or already running; the pre-roll covers the lead-in.
QSOAudioBegin().then(setRecording).catch(() => {});
} else if (isEmpty && !locks.start) {
// Callsign wiped → user abandoned this QSO; reset the timer.
setQsoStartedAt(null);
}
setCallsign(v);
scheduleLookup(v);
// Programmatic call change (clicked spot, or external app via UDP) for a new
// non-empty target → (re)start the recording now, even if one was already
// running for the previous contact. Typed changes (field focused) wait for
// blur so we don't restart per keystroke.
if (v.trim() !== '' && !callFocusedRef.current) {
QSOAudioRestart().then(setRecording).catch(() => {});
}
}
function markEdited(field: string) { userEditedRef.current.add(field); }
@@ -1640,11 +1685,10 @@ export default function App() {
ref={callsignRef}
className="font-mono text-base font-bold tracking-wider uppercase h-9 bg-muted/40 focus:bg-card"
value={callsign}
onFocus={() => { callFocusedRef.current = true; }}
onChange={(e) => onCallsignInput(e.target.value)}
// Start the QSO recording when leaving the callsign field (the pre-roll
// covers the seconds before). No-op when the recorder is off.
onBlur={() => { callFocusedRef.current = false; if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
onBlur={() => { if (callsign.trim()) QSOAudioBegin().then(setRecording).catch(() => {}); }}
/>
</div>
</div>
@@ -1703,13 +1747,13 @@ export default function App() {
</div>
);
const qthBlock = (
<div className="flex flex-col flex-1 min-w-[90px]"><Label className="mb-1 h-3.5">QTH</Label>
<div className="flex flex-col flex-[0.55] min-w-[70px]"><Label className="mb-1 h-3.5">QTH</Label>
<Input value={qth} onChange={(e) => { setQth(e.target.value); markEdited('qth'); }} />
</div>
);
const gridBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">Grid</Label>
<Input value={grid} placeholder="JN05" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
<div className="flex flex-col w-28 shrink-0"><Label className="mb-1 h-3.5">Grid</Label>
<Input value={grid} placeholder="JN05" className="font-mono" onChange={(e) => { setGrid(e.target.value); markEdited('grid'); }} />
</div>
);
// Compact-strip Country (stacked label) + a narrow Comment.
@@ -1899,9 +1943,28 @@ export default function App() {
<Menubar menus={menus} onAction={handleMenu} />
<div className="flex items-center justify-center gap-2 font-mono">
<div className="relative flex items-center justify-center gap-2 font-mono">
{/* Transient toast / error, in the empty band between the menu and
the frequency (left of centre). Single-line + truncated. */}
{(error || toast) && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20 flex items-center max-w-[min(42vw,560px)] font-sans">
{error ? (
<div className="flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/10 text-destructive px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate" title={error}>{error}</span>
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
</div>
) : (
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3" /></button>
</div>
)}
</div>
)}
<div className="flex flex-col items-end leading-none">
<span className="text-[22px] font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
{catState.split && rxFreqMhz && (
<span className="text-[10px] text-muted-foreground mt-0.5">
<span className="text-rose-600 font-semibold mr-1">RX</span>
@@ -1931,14 +1994,7 @@ export default function App() {
both directly clickable, plus an always-visible Stop. The
old Shift/Ctrl shortcuts were not discoverable enough. */}
{(() => {
// Prefer grid-to-grid; fall back to lat/lon when the DX has no
// grid but a known location (e.g. cty.dat-only entities like
// Svalbard → no QRZ grid, but cty.dat gives coordinates).
const myLL = gridToLatLon(station.my_grid);
const p = pathBetween(station.my_grid, grid)
?? (myLL && details.lat != null && details.lon != null
? pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon })
: null);
const p = dxPath;
const disabled = !p;
const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)));
return (
@@ -1986,6 +2042,25 @@ export default function App() {
);
})()}
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
{ubStatus.enabled && (
<div className="inline-flex items-center rounded-full border border-emerald-300 bg-emerald-50 overflow-hidden text-[10px] font-semibold ml-1"
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-muted-foreground/40')} />
</button>
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
<button key={o.d} type="button" disabled={!ubStatus.connected} title={o.t}
onClick={() => { SetUltrabeamDirection(o.d).then(() => setUbStatus((s) => ({ ...s, direction: o.d }))).catch((e: any) => setError(String(e?.message ?? e))); }}
className={cn('px-1.5 py-0.5 transition-colors',
ubStatus.direction === o.d ? 'bg-emerald-600 text-white' : 'text-emerald-800 hover:bg-emerald-100',
!ubStatus.connected && 'opacity-40 cursor-default')}>
{o.l}
</button>
))}
</div>
)}
{/* Voice keyer (DVK) + CW keyer (WinKeyer) quick status/access. */}
<div className="w-px h-4 bg-border mx-1" />
<button
@@ -2009,13 +2084,25 @@ export default function App() {
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
wkStatus.busy ? 'border-amber-300 bg-amber-100 text-amber-800'
: wkEnabled && wkStatus.connected ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: wkEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Zap className="size-4" />
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-amber-500 animate-pulse" />}
</button>
<button
type="button"
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
title={showRotor ? 'Rotor compass — shown · click to hide' : 'Rotor compass · click to show'}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
showRotor ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Compass className="size-4" />
</button>
</div>
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
@@ -2100,24 +2187,6 @@ export default function App() {
</div>
)}
{(error || toast) && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2 max-w-md">
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 text-destructive px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<pre className="flex-1 font-sans whitespace-pre-wrap m-0 leading-snug">{error}</pre>
<button className="ml-1 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3.5" /></button>
</div>
)}
{toast && (
<div className="flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 text-emerald-800 px-3.5 py-2 text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2">
<Satellite className="size-4 shrink-0" />
<span>{toast}</span>
<button className="ml-1 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3.5" /></button>
</div>
)}
</div>
)}
{/* "You have been spotted" banner — shows when our own callsign appears
in a cluster spot (Log4OM-style). Floated as a bottom-center overlay
@@ -2148,7 +2217,7 @@ export default function App() {
className={cn('bg-card shadow-sm border-border',
compact
? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
: 'flex flex-col gap-2 px-3 py-2 flex-1 min-w-[560px] max-w-[760px] border rounded-lg')}
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[760px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
e.preventDefault();
@@ -2243,10 +2312,25 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */}
{!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url) && (
{!compact && (wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. */}
{showRotor && (rotatorHeading.enabled || dxPath) && (
<div className="w-[186px] shrink-0 min-h-0">
<RotorCompass
bearing={dxPath?.bearingShort ?? null}
headings={beamHeadings}
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
/>
</div>
)}
{dvkEnabled && (
<div className="flex-1 min-w-0 min-h-0">
<div className="w-[264px] shrink-0 min-h-0">
<DvkPanel
messages={dvkMsgs}
status={dvkStat}
@@ -2257,7 +2341,7 @@ export default function App() {
</div>
)}
{wkEnabled && (
<div className="flex-1 min-w-0 min-h-0">
<div className="w-[500px] shrink-0 min-h-0">
<WinkeyerPanel
status={wkStatus}
ports={wkPorts}
@@ -2621,8 +2705,8 @@ export default function App() {
// logged without re-typing. n-fer refs (comma-separated)
// become one POTA@ entry each.
applySpotPOTA((s as any).pota_ref);
// (recording (re)starts inside onCallsignInput — the call
// changed programmatically with the field unfocused.)
// New target from a clicked spot → fresh recording + reset timer.
if (s.dx_call.trim()) restartRecordingForNewTarget();
}}
/>
);
@@ -2809,6 +2893,7 @@ export default function App() {
toGrid={grid}
fromLabel={station.callsign}
toLabel={callsign}
beamAzimuths={showBeamOnMap ? beamHeadings : []}
/>
</TabsContent>
@@ -2839,7 +2924,7 @@ export default function App() {
if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call);
applySpotPOTA((s as any).pota_ref);
// (recording (re)starts inside onCallsignInput — programmatic call change)
if (s.dx_call.trim()) restartRecordingForNewTarget();
}}
onClose={() => setShowBandMap(false)}
/>
@@ -2888,42 +2973,6 @@ export default function App() {
disabled={!rotatorHeading.enabled}
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
{ubStatus.enabled && (
<div
className="inline-flex items-center gap-1 px-2 h-5 rounded border border-border text-[11px]"
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam connected') : 'Ultrabeam: connecting…'}
>
<button
type="button"
className="inline-flex items-center gap-1 cursor-pointer hover:text-foreground text-muted-foreground"
onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }}
title="Antenna settings"
>
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-muted-foreground/40')} />
Ant
</button>
{([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: '180°' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => (
<button
key={o.d}
type="button"
disabled={!ubStatus.connected}
title={o.t}
onClick={() => {
SetUltrabeamDirection(o.d)
.then(() => setUbStatus((s) => ({ ...s, direction: o.d })))
.catch((e: any) => setError(String(e?.message ?? e)));
}}
className={cn(
'px-1 rounded text-[10px] font-medium transition-colors',
ubStatus.direction === o.d ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted',
!ubStatus.connected && 'opacity-40 cursor-default',
)}
>
{o.l}
</button>
))}
</div>
)}
<div className="flex-1" />
</footer>
);
+2 -2
View File
@@ -41,7 +41,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
No messages recorded yet. Open <strong>Settings Audio devices &amp; voice keyer</strong> to record F1F6.
</div>
) : (
<div className="grid grid-cols-2 gap-1.5">
<div className="grid grid-cols-1 gap-1">
{messages.map((m) => (
<button
key={m.slot}
@@ -50,7 +50,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
onClick={() => onPlay(m.slot)}
title={m.has_audio ? `Transmit F${m.slot}${m.label ? ' — ' + m.label : ''} (${m.duration_sec.toFixed(1)}s)` : `F${m.slot} — empty`}
className={cn(
'flex items-center gap-1.5 rounded-md border px-2 py-1.5 text-left transition-colors',
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors',
m.has_audio
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
+3 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
import { writeUiPref } from '@/lib/uiPref';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -106,7 +107,8 @@ function loadPresets(): Record<string, QueryFilter> {
try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}'); } catch { return {}; }
}
function savePresets(p: Record<string, QueryFilter>) {
localStorage.setItem(PRESETS_KEY, JSON.stringify(p));
// Write-through to the portable DB so presets travel with the data/ folder.
writeUiPref(PRESETS_KEY, JSON.stringify(p));
}
interface Props {
+55 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween } from '@/lib/maidenhead';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween, destinationPoint } from '@/lib/maidenhead';
// MainMap — Log4OM-style dual map for the Main tab:
// • Left: a world map with the great-circle path drawn from the operator to
@@ -15,6 +15,26 @@ interface Props {
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
}
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
function unwrapLon(ring: [number, number][]): [number, number][] {
const out: [number, number][] = [];
let prev = NaN;
for (const [la, lo] of ring) {
let lon = lo;
if (!Number.isNaN(prev)) {
while (lon - prev > 180) lon -= 360;
while (lon - prev < -180) lon += 360;
}
prev = lon;
out.push([la, lon]);
}
return out;
}
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
@@ -31,7 +51,7 @@ function dot(color: string): L.DivIcon {
});
}
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) {
const worldRef = useRef<HTMLDivElement>(null);
const locatorRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null);
@@ -75,6 +95,37 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid);
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
if (from && beamAzimuths && beamAzimuths.length) {
const half = (beamWidth ?? 30) / 2;
const D = 9000; // lobe length (km)
const radial = (b: number): [number, number][] =>
Array.from({ length: 14 }, (_, i) => {
const d = destinationPoint(from.lat, from.lon, b, (D * (i + 1)) / 14);
return [d.lat, d.lon] as [number, number];
});
for (const az of beamAzimuths) {
const arc: [number, number][] = [];
for (let b = az - half; b <= az + half + 0.001; b += 2) {
const d = destinationPoint(from.lat, from.lon, b, D);
arc.push([d.lat, d.lon]);
}
const ring = unwrapLon([
[from.lat, from.lon],
...radial(az - half),
...arc,
...radial(az + half).reverse(),
]);
L.polygon(ring as L.LatLngExpression[], {
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
}).addTo(wo);
// Boresight (dashed centre line).
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
}
}
// ── Left: world + great-circle arc ──
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
if (to) {
@@ -108,7 +159,8 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
lm.setView([from.lat, from.lon], 5);
}
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
}, [fromGrid, toGrid, fromLabel, toLabel]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth]);
const path = pathBetween(fromGrid, toGrid);
+138
View File
@@ -0,0 +1,138 @@
// RotorCompass — an azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
//
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
// a green azimuth bezel. A green needle shows the antenna heading (two needles
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
// to turn the antenna there.
import { useMemo } from 'react';
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
import { feature } from 'topojson-client';
import landTopo from 'world-atlas/land-110m.json';
import { Compass, X } from 'lucide-react';
import { cn } from '@/lib/utils';
// Decode the coastline outline once (≈110 m simplified land polygons).
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
const GRATICULE = geoGraticule10();
interface Props {
bearing?: number | null; // short-path azimuth to DX (deg)
headings: number[]; // antenna heading(s) — rotor + Ultrabeam pattern
centerLat?: number | null; // operator latitude (projection centre)
centerLon?: number | null; // operator longitude
rotorEnabled?: boolean;
onGoto?: (az: number) => void; // click-to-turn
onClose?: () => void;
}
const SIZE = 168;
const C = SIZE / 2;
const R = C - 6; // outer bezel radius
const MAP_R = R - 6; // map/clip radius (inside the bezel)
function pt(az: number, radius: number): [number, number] {
const a = ((az - 90) * Math.PI) / 180;
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
[],
);
// Project the world centred on the QTH (north up; antipode at the bezel).
const { land, grat } = useMemo(() => {
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
const proj = geoAzimuthalEquidistant()
.rotate([-centerLon, -centerLat])
.clipAngle(179.9)
.scale(MAP_R / Math.PI)
.translate([C, C]);
const path = geoPath(proj as any);
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
}, [centerLat, centerLon]);
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (!onGoto) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
az = ((az % 360) + 360) % 360;
onGoto(Math.round(az));
}
const headLabel = headings.length ? headings[0] : null;
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header — matches the WinKeyer / Voice keyer panels. */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Compass className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" />
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
</span>
{onClose && (
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex items-center justify-center p-2 min-h-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
style={{ width: SIZE, height: SIZE }}
onClick={handleClick}
>
<defs>
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
</defs>
{/* water + world map, clipped to the dial */}
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
<g clipPath="url(#rotorDial)">
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
</g>
{/* green azimuth bezel */}
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
{/* ticks every 10°, longer at 30° */}
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
const major = d % 30 === 0;
const [x1, y1] = pt(d, MAP_R);
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
})}
{/* cardinal labels + degree numbers at 45° */}
{cardinals.map(({ d, l }) => {
const [x, y] = pt(d, MAP_R - 13);
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
})}
{/* DX short-path bearing → small red marker on the bezel */}
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
); })()}
{/* antenna heading needle(s) — green; two when bidirectional */}
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
<g key={i}>
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
</g>
); })}
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
</div>
</section>
);
}
+33 -2
View File
@@ -47,6 +47,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref';
import { OperatingPanel } from '@/components/OperatingPanel';
import { UDPIntegrationsPanel } from '@/components/UDPIntegrationsPanel';
@@ -421,8 +422,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const [dvkErr, setDvkErr] = useState('');
// General behaviour prefs (machine-local, applied live via localStorage).
// General behaviour prefs (mirrored to the DB so they travel with data/).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
const [showBeamMap, setShowBeamMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
// E-mail / SMTP (send QSO recordings).
type EmailCfg = {
@@ -2897,7 +2900,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={autofocusWB}
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); localStorage.setItem('opslog.autofocusWB', v ? '1' : '0'); }}
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); writeUiPref('opslog.autofocusWB', v ? '1' : '0'); }}
className="mt-0.5"
/>
<span>
@@ -2908,6 +2911,34 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={showBeamMap}
onCheckedChange={(c) => { const v = !!c; setShowBeamMap(v); writeUiPref('opslog.showBeamOnMap', v ? '1' : '0'); }}
className="mt-0.5"
/>
<span>
Show the antenna beam heading on the Main map
<span className="block text-xs text-muted-foreground mt-0.5">
Draws the beam lobe at the rotor heading (and the opposite/both directions when an Ultrabeam is reversed or bidirectional). Turn off to hide it.
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={startEqEnd}
onCheckedChange={(c) => { const v = !!c; setStartEqEnd(v); writeUiPref('opslog.startEqualsEnd', v ? '1' : '0'); }}
className="mt-0.5"
/>
<span>
QSO start time = end time (log at completion)
<span className="block text-xs text-muted-foreground mt-0.5">
Sets TIME_ON equal to TIME_OFF the moment you log the QSO instead of when you first entered the call. Useful when you call a station for a while: the logged time then matches the other operator's, so LoTW/eQSL confirmations match.
</span>
</span>
</label>
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
<label className="flex items-start gap-2 text-sm cursor-pointer">
+27 -24
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Radio, Square, Send, Plug, Power, RefreshCw, X } from 'lucide-react';
import { Radio, Square, Send, Plug, Power, RefreshCw, X, ChevronUp, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -51,8 +51,14 @@ export function WinkeyerPanel({
}: Props) {
const [cwText, setCwText] = useState('');
const [speed, setSpeed] = useState(wpm);
// Step the speed (compact +/- control replaces the old slider).
const changeSpeed = (delta: number) => {
const w = Math.max(5, Math.min(50, speed + delta));
setSpeed(w);
onSetSpeed(w);
};
// Keep the local speed slider in sync when the device/config changes it.
// Keep the local speed in sync when the device/config changes it.
useEffect(() => { setSpeed(status.connected ? status.wpm || wpm : wpm); }, [status.wpm, status.connected, wpm]);
const connected = status.connected;
@@ -113,24 +119,10 @@ export function WinkeyerPanel({
</Button>
</div>
<div className="flex flex-col gap-2 px-3 pb-2 min-h-0 overflow-y-auto">
{/* Speed */}
<div className="flex flex-col gap-1.5 px-3 pb-2 min-h-0 overflow-y-auto">
{/* Live transmitted text (echoed by the keyer) + compact speed stepper. */}
<div className="flex items-center gap-2">
<Label className="text-xs w-12 shrink-0">Speed</Label>
<input
type="range" min={5} max={50} value={speed}
onChange={(e) => setSpeed(parseInt(e.target.value, 10))}
onMouseUp={() => onSetSpeed(speed)}
onTouchEnd={() => onSetSpeed(speed)}
disabled={!connected}
className="flex-1 accent-primary"
/>
<span className="font-mono text-sm font-bold w-14 text-right">{speed} wpm</span>
</div>
{/* Live transmitted text (echoed by the keyer as it sends). */}
<div className="flex items-center gap-2">
<Label className="text-xs w-12 shrink-0">TX</Label>
<Label className="text-xs w-8 shrink-0">TX</Label>
<div className={cn(
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
status.busy ? 'text-emerald-700' : 'text-muted-foreground',
@@ -138,6 +130,17 @@ export function WinkeyerPanel({
{sent || <span className="opacity-50"></span>}
{status.busy && <span className="ml-0.5 animate-pulse"></span>}
</div>
{/* Speed: number + up/down arrows (replaces the slider, saves height). */}
<div className="flex items-center gap-1 shrink-0 h-8 rounded-md border border-border bg-muted/20 pl-2 pr-1" title="CW speed (WPM)">
<span className="font-mono text-sm font-bold tabular-nums">{speed}</span>
<span className="text-[9px] text-muted-foreground">wpm</span>
<div className="flex flex-col -my-0.5">
<button type="button" disabled={!connected} onClick={() => changeSpeed(+1)} title="Faster"
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronUp className="size-3.5" /></button>
<button type="button" disabled={!connected} onClick={() => changeSpeed(-1)} title="Slower"
className="text-muted-foreground hover:text-foreground leading-none disabled:opacity-40"><ChevronDown className="size-3.5" /></button>
</div>
</div>
</div>
{/* CW text */}
@@ -169,8 +172,8 @@ export function WinkeyerPanel({
</Button>
</div>
{/* Macro buttons F1… */}
<div className="grid grid-cols-3 gap-1.5">
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
<div className="grid grid-cols-3 gap-1">
{macros.map((m, i) => (
<button
key={i}
@@ -179,12 +182,12 @@ export function WinkeyerPanel({
disabled={!connected}
title={m.text}
className={cn(
'flex flex-col items-start rounded-md border border-border px-2 py-1 text-left transition-colors',
'flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-left transition-colors',
connected ? 'hover:border-primary/60 hover:bg-accent/40' : 'opacity-50 cursor-not-allowed',
)}
>
<span className="text-[10px] font-mono text-primary font-semibold">F{i + 1}</span>
<span className="text-xs font-medium truncate w-full">{m.label || `Macro ${i + 1}`}</span>
<span className="text-[10px] font-mono text-primary font-semibold shrink-0">F{i + 1}</span>
<span className="text-xs font-medium truncate">{m.label || `Macro ${i + 1}`}</span>
</button>
))}
</div>
+15
View File
@@ -174,3 +174,18 @@ export function greatCirclePoints(
function toRad(d: number): number { return (d * Math.PI) / 180; }
function toDeg(r: number): number { return (r * 180) / Math.PI; }
// destinationPoint returns the lat/lon reached from (lat,lon) by travelling
// distanceKm along the great circle at the given initial bearing. Used to draw
// the antenna beam lobe (a sector of bearings) on the world map.
export function destinationPoint(lat: number, lon: number, bearingDeg: number, distanceKm: number): { lat: number; lon: number } {
const delta = distanceKm / EARTH_KM;
const theta = toRad(bearingDeg);
const phi1 = toRad(lat), lam1 = toRad(lon);
const sinPhi2 = Math.sin(phi1) * Math.cos(delta) + Math.cos(phi1) * Math.sin(delta) * Math.cos(theta);
const phi2 = Math.asin(Math.max(-1, Math.min(1, sinPhi2)));
const y = Math.sin(theta) * Math.sin(delta) * Math.cos(phi1);
const x = Math.cos(delta) - Math.sin(phi1) * sinPhi2;
const lam2 = lam1 + Math.atan2(y, x);
return { lat: toDeg(phi2), lon: ((toDeg(lam2) + 540) % 360) - 180 };
}
+46
View File
@@ -0,0 +1,46 @@
// Portable UI preferences.
//
// A handful of small UI prefs historically lived only in the WebView's
// localStorage, so they did NOT travel when the operator copied the OpsLog
// folder (data/) to another machine. These helpers mirror them into the DB
// settings table (ui.* keys, like the grid columns) so the whole setup is
// identical after a copy.
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
// Keys that must travel with data/ (DB is the portable source of truth; the
// localStorage copy is just a fast, synchronous cache).
const PORTABLE_KEYS = [
'hamlog.qsoLimit', // QSO list page size
'bandmap.side', // band map docked left / right
'opslog.autofocusWB', // auto-focus Worked-before
'hamlog.filterPresets', // Filter Builder saved presets
'opslog.showRotor', // rotor compass shown next to the keyers
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
];
// syncPortablePrefs reconciles the DB with the local cache at startup:
// • DB has a value → copy it into localStorage (a copied folder restores it);
// • DB empty, local set → seed the DB from local (migrates the current value).
// Call it BEFORE the first render so the app's synchronous localStorage reads
// already see the portable values — no per-component hydration needed.
export async function syncPortablePrefs(): Promise<void> {
await Promise.all(PORTABLE_KEYS.map(async (key) => {
try {
const db = await GetUIPref(key);
const local = localStorage.getItem(key);
if (db != null && db !== '') {
if (db !== local) { try { localStorage.setItem(key, db); } catch { /* quota */ } }
} else if (local != null && local !== '') {
await SetUIPref(key, local);
}
} catch { /* backend not ready / no DB — keep whatever is in localStorage */ }
}));
}
// writeUiPref write-throughs a value to the local cache AND the portable DB.
// Use it everywhere these keys are written instead of localStorage.setItem.
export function writeUiPref(key: string, value: string): void {
try { localStorage.setItem(key, value); } catch { /* quota / private mode */ }
SetUIPref(key, value).catch(() => { /* DB unavailable — the cache still holds it */ });
}
+8 -2
View File
@@ -2,13 +2,19 @@ import React from 'react'
import {createRoot} from 'react-dom/client'
import './style.css'
import App from './App'
import { syncPortablePrefs } from './lib/uiPref'
const container = document.getElementById('root')
const root = createRoot(container!)
root.render(
// Pull portable UI prefs (DB → localStorage) before the first render so the
// app's synchronous reads see the values copied along with the data/ folder.
// Render regardless of the outcome so a backend hiccup never blocks startup.
syncPortablePrefs().finally(() => {
root.render(
<React.StrictMode>
<App/>
</React.StrictMode>
)
)
})
+6 -2
View File
@@ -50,11 +50,15 @@
@layer base {
* { @apply border-border; }
html, body, #root { height: 100%; }
/* 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. */
html { font-size: 13px; }
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.45;
font-size: 0.95rem;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}