From 6542504a4bda6f63dbda933044d3aef546b39095 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 7 Jun 2026 21:44:49 +0200 Subject: [PATCH] up --- frontend/package-lock.json | 97 ++++++++- frontend/package.json | 7 +- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 243 +++++++++++++--------- frontend/src/components/DvkPanel.tsx | 4 +- frontend/src/components/FilterBuilder.tsx | 4 +- frontend/src/components/MainMap.tsx | 58 +++++- frontend/src/components/RotorCompass.tsx | 138 ++++++++++++ frontend/src/components/SettingsModal.tsx | 35 +++- frontend/src/components/WinkeyerPanel.tsx | 51 ++--- frontend/src/lib/maidenhead.ts | 15 ++ frontend/src/lib/uiPref.ts | 46 ++++ frontend/src/main.tsx | 16 +- frontend/src/style.css | 8 +- 14 files changed, 585 insertions(+), 139 deletions(-) create mode 100644 frontend/src/components/RotorCompass.tsx create mode 100644 frontend/src/lib/uiPref.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dd9359a..43b1514 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 6abfc0d..26e65e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 98b7e46..693b40b 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -c98874941451e4e6ffa48f22c1d764e7 \ No newline at end of file +f9b41e192918fa2511f68cd1b361fcd3 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9493bad..191d06a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); @@ -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(null); const [selectedId, setSelectedId] = useState(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(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(() => { + 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>({}); @@ -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(() => {}); }} /> @@ -1703,13 +1747,13 @@ export default function App() { ); const qthBlock = ( -
+
{ setQth(e.target.value); markEdited('qth'); }} />
); const gridBlock = ( -
- { setGrid(e.target.value); markEdited('grid'); }} /> +
+ { setGrid(e.target.value); markEdited('grid'); }} />
); // Compact-strip Country (stacked label) + a narrow Comment. @@ -1899,9 +1943,28 @@ export default function App() { -
+
+ {/* Transient toast / error, in the empty band between the menu and + the frequency (left of centre). Single-line + truncated. */} + {(error || toast) && ( +
+ {error ? ( +
+ + {error} + +
+ ) : ( +
+ + {toast} + +
+ )} +
+ )}
- {freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'} + {freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'} {catState.split && rxFreqMhz && ( RX @@ -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 && ( +
+ + {([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: 'Reverse (180°)' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => ( + + ))} +
+ )} + {/* Voice keyer (DVK) + CW keyer (WinKeyer) quick status/access. */}
+
@@ -2100,24 +2187,6 @@ export default function App() {
)} - {(error || toast) && ( -
- {error && ( -
- -
{error}
- -
- )} - {toast && ( -
- - {toast} - -
- )} -
- )} {/* "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))) && (
+ {/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a + rotator is configured or a DX bearing exists. */} + {showRotor && (rotatorHeading.enabled || dxPath) && ( +
+ RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))} + onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }} + /> +
+ )} {dvkEnabled && ( -
+
)} {wkEnabled && ( -
+
); @@ -2809,6 +2893,7 @@ export default function App() { toGrid={grid} fromLabel={station.callsign} toLabel={callsign} + beamAzimuths={showBeamOnMap ? beamHeadings : []} /> @@ -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 && ( -
- - {([{ d: 0, l: 'N', t: 'Normal' }, { d: 1, l: '180°', t: '180°' }, { d: 2, l: 'Bi', t: 'Bidirectional' }]).map((o) => ( - - ))} -
- )}
); diff --git a/frontend/src/components/DvkPanel.tsx b/frontend/src/components/DvkPanel.tsx index 0cc0145..6257ec5 100644 --- a/frontend/src/components/DvkPanel.tsx +++ b/frontend/src/components/DvkPanel.tsx @@ -41,7 +41,7 @@ export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) { No messages recorded yet. Open Settings → Audio devices & voice keyer to record F1–F6.
) : ( -
+
{messages.map((m) => ( + )} +
+ +
+ + + + + {/* water + world map, clipped to the dial */} + + + {grat && } + {land && } + + {/* green azimuth bezel */} + + + {/* 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 ; + })} + {/* cardinal labels + degree numbers at 45° */} + {cardinals.map(({ d, l }) => { + const [x, y] = pt(d, MAP_R - 13); + return 1 ? 7 : 9, fontWeight: 700 }}>{l}; + })} + + {/* DX short-path bearing → small red marker on the bezel */} + {bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return ( + + ); })()} + + {/* antenna heading needle(s) — green; two when bidirectional */} + {headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return ( + + + + + ); })} + + +
+ + ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index f4c6ae5..73c3114 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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({ 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) { + + + +

ClubLog exceptions (DXpedition overrides)

-
- {/* Speed */} +
+ {/* Live transmitted text (echoed by the keyer) + compact speed stepper. */}
- - setSpeed(parseInt(e.target.value, 10))} - onMouseUp={() => onSetSpeed(speed)} - onTouchEnd={() => onSetSpeed(speed)} - disabled={!connected} - className="flex-1 accent-primary" - /> - {speed} wpm -
- - {/* Live transmitted text (echoed by the keyer as it sends). */} -
- +
—} {status.busy && }
+ {/* Speed: number + up/down arrows (replaces the slider, saves height). */} +
+ {speed} + wpm +
+ + +
+
{/* CW text */} @@ -169,8 +172,8 @@ export function WinkeyerPanel({
- {/* Macro buttons F1… */} -
+ {/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */} +
{macros.map((m, i) => ( ))}
diff --git a/frontend/src/lib/maidenhead.ts b/frontend/src/lib/maidenhead.ts index cdbee60..582e08c 100644 --- a/frontend/src/lib/maidenhead.ts +++ b/frontend/src/lib/maidenhead.ts @@ -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 }; +} diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts new file mode 100644 index 0000000..7cf2be8 --- /dev/null +++ b/frontend/src/lib/uiPref.ts @@ -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 { + 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 */ }); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3626ff3..aafe7db 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( + + + + ) +}) diff --git a/frontend/src/style.css b/frontend/src/style.css index bd3454d..70e6766 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; }