up
This commit is contained in:
Binary file not shown.
+56
-4
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock,
|
AlertCircle, Antenna, CheckCircle2, Clock, Compass, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X,
|
Maximize2, Minimize2, Mic, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -68,7 +68,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead';
|
import { pathBetween, pathBetweenLatLon, gridToLatLon, latLonToGrid } from '@/lib/maidenhead';
|
||||||
import { flagURL } from '@/lib/flags';
|
import { flagURL } from '@/lib/flags';
|
||||||
|
|
||||||
type QSO = QSOForm;
|
type QSO = QSOForm;
|
||||||
@@ -1316,7 +1316,14 @@ export default function App() {
|
|||||||
const ue = userEditedRef.current;
|
const ue = userEditedRef.current;
|
||||||
if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? '');
|
if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? '');
|
||||||
if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? '');
|
if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? '');
|
||||||
if (!ue.has('grid') && (r.grid ?? '') !== '') setGrid(r.grid ?? '');
|
if (!ue.has('grid')) {
|
||||||
|
if ((r.grid ?? '') !== '') setGrid(r.grid ?? '');
|
||||||
|
// No provider grid (cty.dat-only / portable): derive a 4-char grid from
|
||||||
|
// the entity centroid (e.g. Svalbard → JQ88) so the field — and the
|
||||||
|
// bearing/map — aren't empty. 4 chars signals it's entity-level, not a
|
||||||
|
// precise QTH (matches how Log4OM shows it).
|
||||||
|
else if (r.lat || r.lon) setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
|
||||||
|
}
|
||||||
// Country/zones are exactly what cty.dat IS authoritative for — set them
|
// Country/zones are exactly what cty.dat IS authoritative for — set them
|
||||||
// (only skipped if empty, so we never blank a known country).
|
// (only skipped if empty, so we never blank a known country).
|
||||||
if (!ue.has('country') && (r.country ?? '') !== '') setCountry(r.country ?? '');
|
if (!ue.has('country') && (r.country ?? '') !== '') setCountry(r.country ?? '');
|
||||||
@@ -1353,6 +1360,14 @@ export default function App() {
|
|||||||
lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400);
|
lookupTimerRef.current = window.setTimeout(() => runLookup(call), 400);
|
||||||
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
wbTimerRef.current = window.setTimeout(() => runWorkedBefore(call), 150);
|
||||||
}
|
}
|
||||||
|
// applySpotPOTA sets the QSO's POTA award reference(s) from a clicked spot's
|
||||||
|
// park ref ("US-4164" or n-fer "US-1,US-2"). Empty ref clears it (fresh
|
||||||
|
// target). Routed to the pota_ref column at save via applyAwardRefs.
|
||||||
|
function applySpotPOTA(potaRef?: string) {
|
||||||
|
const refs = String(potaRef || '')
|
||||||
|
.split(/[,;]/).map((x) => x.trim().toUpperCase()).filter(Boolean);
|
||||||
|
setDetails((d) => ({ ...d, award_refs: refs.map((r) => `POTA@${r}`).join(';') }));
|
||||||
|
}
|
||||||
function onCallsignInput(v: string) {
|
function onCallsignInput(v: string) {
|
||||||
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
||||||
// on every status packet. If it matches what's already in the entry,
|
// on every status packet. If it matches what's already in the entry,
|
||||||
@@ -1576,7 +1591,7 @@ export default function App() {
|
|||||||
// them as shared consts avoids duplicating the (large) per-field JSX +
|
// them as shared consts avoids duplicating the (large) per-field JSX +
|
||||||
// handlers across the two layouts.
|
// handlers across the two layouts.
|
||||||
const callsignBlock = (
|
const callsignBlock = (
|
||||||
<div className="flex flex-col w-36">
|
<div className="flex flex-col w-44">
|
||||||
<Label className="mb-1 flex items-center gap-2 h-3.5">
|
<Label className="mb-1 flex items-center gap-2 h-3.5">
|
||||||
Callsign
|
Callsign
|
||||||
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up…</Badge>}
|
{lookupBusy && <Badge variant="secondary" className="text-[9px] py-0 px-1.5 normal-case font-medium tracking-wider"><Loader2 className="size-2.5 mr-1 animate-spin" />Looking up…</Badge>}
|
||||||
@@ -1944,6 +1959,37 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Voice keyer (DVK) + CW keyer (WinKeyer) quick status/access. */}
|
||||||
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDvkEnabled((v) => !v)}
|
||||||
|
title={dvkStat.playing ? 'Voice keyer — playing' : dvkEnabled ? 'Voice keyer (DVK) — open · click to close' : 'Voice keyer (DVK) · click to open'}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
dvkStat.playing ? 'border-rose-300 bg-rose-100 text-rose-700'
|
||||||
|
: dvkEnabled ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Mic className="size-4" />
|
||||||
|
{dvkStat.playing && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-rose-500 animate-pulse" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => wkSetEnabled(!wkEnabled)}
|
||||||
|
title={wkEnabled && wkStatus.connected ? `CW keyer (WinKeyer) — connected${wkStatus.busy ? ', sending' : ''} · click to close` : wkEnabled ? 'CW keyer (WinKeyer) — enabled · click to close' : 'CW keyer (WinKeyer) · click to open'}
|
||||||
|
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'
|
||||||
|
: '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>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
@@ -2542,6 +2588,11 @@ export default function App() {
|
|||||||
if (m) setMode(m);
|
if (m) setMode(m);
|
||||||
}
|
}
|
||||||
onCallsignInput(s.dx_call);
|
onCallsignInput(s.dx_call);
|
||||||
|
// A POTA spot carries the park ref — pre-fill the POTA
|
||||||
|
// award reference (like the State→RAC auto-match) so it's
|
||||||
|
// logged without re-typing. n-fer refs (comma-separated)
|
||||||
|
// become one POTA@ entry each.
|
||||||
|
applySpotPOTA((s as any).pota_ref);
|
||||||
// Clicking a spot fills the call programmatically (no blur
|
// Clicking a spot fills the call programmatically (no blur
|
||||||
// on the call field), so start the QSO recording here too.
|
// on the call field), so start the QSO recording here too.
|
||||||
if (s.dx_call.trim()) QSOAudioBegin().then(setRecording).catch(() => {});
|
if (s.dx_call.trim()) QSOAudioBegin().then(setRecording).catch(() => {});
|
||||||
@@ -2760,6 +2811,7 @@ export default function App() {
|
|||||||
if (m) setMode(m);
|
if (m) setMode(m);
|
||||||
}
|
}
|
||||||
onCallsignInput(s.dx_call);
|
onCallsignInput(s.dx_call);
|
||||||
|
applySpotPOTA((s as any).pota_ref);
|
||||||
if (s.dx_call.trim()) QSOAudioBegin().then(setRecording).catch(() => {});
|
if (s.dx_call.trim()) QSOAudioBegin().then(setRecording).catch(() => {});
|
||||||
}}
|
}}
|
||||||
onClose={() => setShowBandMap(false)}
|
onClose={() => setShowBandMap(false)}
|
||||||
|
|||||||
@@ -280,6 +280,19 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues }: Props)
|
|||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{/* Add an UNLISTED reference: type a new code (e.g. a brand-new POTA park
|
||||||
|
not yet in the list) and add it directly. */}
|
||||||
|
{q.trim().length >= 2 && !results.some((r) => r.code.toUpperCase() === q.trim().toUpperCase()) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { addRef({ code: q.trim().toUpperCase(), name: '' } as AwardRef); setQ(''); }}
|
||||||
|
className="text-left rounded border border-dashed border-primary/50 px-1.5 py-1 text-[11px] text-primary hover:bg-primary/10"
|
||||||
|
title="Add this reference even though it isn't in the list yet (new / unlisted)"
|
||||||
|
>
|
||||||
|
+ Add <span className="font-mono font-semibold">{q.trim().toUpperCase()}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground"> (unlisted)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
|
||||||
{busy && (
|
{busy && (
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
|
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
|
||||||
|
|||||||
@@ -49,6 +49,31 @@ export function gridToLatLon(grid: string): { lat: number; lon: number } | null
|
|||||||
return { lat, lon };
|
return { lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// latLonToGrid encodes a lat/lon to a Maidenhead locator (default 6 chars).
|
||||||
|
// Inverse of gridToLatLon. Used to derive a default grid from a cty.dat entity
|
||||||
|
// centroid when no provider grid is available (e.g. a portable/rare DX call) —
|
||||||
|
// a centre-of-entity locator is more useful than an empty field.
|
||||||
|
export function latLonToGrid(lat: number, lon: number, precision = 6): string {
|
||||||
|
let adjLon = Math.min(359.9999, Math.max(0, lon + 180));
|
||||||
|
let adjLat = Math.min(179.9999, Math.max(0, lat + 90));
|
||||||
|
const A = 'A'.charCodeAt(0);
|
||||||
|
const a = 'a'.charCodeAt(0);
|
||||||
|
const fLon = Math.floor(adjLon / 20);
|
||||||
|
const fLat = Math.floor(adjLat / 10);
|
||||||
|
adjLon -= fLon * 20;
|
||||||
|
adjLat -= fLat * 10;
|
||||||
|
const sLon = Math.floor(adjLon / 2);
|
||||||
|
const sLat = Math.floor(adjLat);
|
||||||
|
let grid = String.fromCharCode(A + fLon) + String.fromCharCode(A + fLat) + sLon + sLat;
|
||||||
|
if (precision >= 6) {
|
||||||
|
adjLon -= sLon * 2;
|
||||||
|
adjLat -= sLat;
|
||||||
|
grid += String.fromCharCode(a + Math.min(23, Math.floor(adjLon * 12)))
|
||||||
|
+ String.fromCharCode(a + Math.min(23, Math.floor(adjLat * 24)));
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
// gridSquareBounds returns the SW/NE corners of a Maidenhead square so a map
|
// gridSquareBounds returns the SW/NE corners of a Maidenhead square so a map
|
||||||
// can draw its outline. Half-extents shrink with locator precision.
|
// can draw its outline. Half-extents shrink with locator precision.
|
||||||
export function gridSquareBounds(grid: string):
|
export function gridSquareBounds(grid: string):
|
||||||
|
|||||||
Reference in New Issue
Block a user