From 9189f54df5e62dd252bcfe5cd87a08f0fc056f9c Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 7 Jun 2026 12:50:04 +0200 Subject: [PATCH] up --- frontend/src/App.tsx | 11 +++++- frontend/src/components/DetailsPanel.tsx | 17 +++++--- frontend/src/lib/maidenhead.ts | 10 +++++ internal/dxcc/dxcc.go | 11 +++++- internal/dxcc/dxcc_test.go | 16 ++++++++ internal/lookup/homecall_test.go | 21 ++++++++++ internal/lookup/lookup.go | 49 ++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 internal/lookup/homecall_test.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cbca689..d7a1565 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -68,7 +68,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; -import { pathBetween } from '@/lib/maidenhead'; +import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead'; import { flagURL } from '@/lib/flags'; type QSO = QSOForm; @@ -1890,7 +1890,14 @@ export default function App() { both directly clickable, plus an always-visible Stop. The old Shift/Ctrl shortcuts were not discoverable enough. */} {(() => { - const p = pathBetween(station.my_grid, grid); + // 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 disabled = !p; const goto = (az: number) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err))); return ( diff --git a/frontend/src/components/DetailsPanel.tsx b/frontend/src/components/DetailsPanel.tsx index 1fec3be..7cd57ed 100644 --- a/frontend/src/components/DetailsPanel.tsx +++ b/frontend/src/components/DetailsPanel.tsx @@ -6,7 +6,7 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; -import { pathBetween } from '@/lib/maidenhead'; +import { pathBetween, pathBetweenLatLon, gridToLatLon } from '@/lib/maidenhead'; import { BandSlotGrid } from '@/components/BandSlotGrid'; import { AwardRefSelector } from '@/components/AwardRefSelector'; @@ -122,10 +122,17 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, const open = tab ?? internalOpen; // controlled when `tab` is provided // Bearing/distance from operator's home grid to the remote station. // Recomputed only when either grid actually changes. - const path = useMemo( - () => pathBetween(operatorGrid, remoteGrid), - [operatorGrid, remoteGrid], - ); + const path = useMemo(() => { + const byGrid = pathBetween(operatorGrid, remoteGrid); + if (byGrid) return byGrid; + // Fall back to lat/lon when the DX has coordinates but no grid (e.g. a + // cty.dat-only entity like Svalbard: no QRZ grid, but cty.dat coordinates). + const myLL = gridToLatLon(operatorGrid); + if (myLL && details.lat != null && details.lon != null) { + return pathBetweenLatLon(myLL, { lat: details.lat, lon: details.lon }); + } + return null; + }, [operatorGrid, remoteGrid, details.lat, details.lon]); const fmtDeg = (n: number) => `${Math.round(n)}°`; const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`; diff --git a/frontend/src/lib/maidenhead.ts b/frontend/src/lib/maidenhead.ts index 657a4cd..f1c0538 100644 --- a/frontend/src/lib/maidenhead.ts +++ b/frontend/src/lib/maidenhead.ts @@ -77,6 +77,16 @@ export function pathBetween(fromGrid: string, toGrid: string): PathInfo | null { const a = gridToLatLon(fromGrid); const b = gridToLatLon(toGrid); if (!a || !b) return null; + return pathBetweenLatLon(a, b); +} + +// pathBetweenLatLon computes the great-circle path between two lat/lon points. +// Used as a fallback when a station has a known location (e.g. cty.dat entity +// coordinates for Svalbard) but no Maidenhead grid. +export function pathBetweenLatLon( + a: { lat: number; lon: number }, + b: { lat: number; lon: number }, +): PathInfo { const φ1 = toRad(a.lat); const φ2 = toRad(b.lat); const Δλ = toRad(b.lon - a.lon); diff --git a/internal/dxcc/dxcc.go b/internal/dxcc/dxcc.go index c0b2d11..49641b5 100644 --- a/internal/dxcc/dxcc.go +++ b/internal/dxcc/dxcc.go @@ -209,7 +209,13 @@ func parseEntityHeader(line string) *Entity { e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1])) e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2])) e.Lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[4]), 64) - e.Lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[5]), 64) + // cty.dat longitude is "+ for West" (e.g. France 2°E = -2.00, USA 92°W = + // +91.87). Negate it to the standard "+ for East" the rest of the app uses + // (grids, bearing/distance math), otherwise every cty.dat-derived azimuth and + // cluster distance is mirrored east↔west. + if lon, err := strconv.ParseFloat(strings.TrimSpace(parts[5]), 64); err == nil { + e.Lon = -lon + } e.TZOffset, _ = strconv.ParseFloat(strings.TrimSpace(parts[6]), 64) if e.Name == "" { return nil @@ -241,7 +247,8 @@ func parsePrefix(s string, e *Entity) (prefixEntry, bool) { lat, e1 := strconv.ParseFloat(a, 64) lon, e2 := strconv.ParseFloat(b, 64) if e1 == nil && e2 == nil { - out.latOverride, out.lonOverride = lat, lon + // Same "+ for West" → "+ for East" flip as the entity header. + out.latOverride, out.lonOverride = lat, -lon out.hasLatLon = true } } diff --git a/internal/dxcc/dxcc_test.go b/internal/dxcc/dxcc_test.go index 309c22c..8440030 100644 --- a/internal/dxcc/dxcc_test.go +++ b/internal/dxcc/dxcc_test.go @@ -17,6 +17,22 @@ United States: 05: 08: NA: 37.53: 91.67: 5.0: K: =W1AW(5)[7],K,N,W,AA,AB,AC,AD,AE,AF,AG,AH,AI,AJ,AK; ` +// cty.dat stores longitude "+ for West"; we normalise to the standard +// "+ for East" so bearing/distance math (and the cluster map) aren't mirrored. +// Regression guard for the Svalbard-azimuth bug (356° instead of 4°). +func TestLongitudeSign(t *testing.T) { + db, err := Load(strings.NewReader(sampleCty)) + if err != nil { + t.Fatalf("load: %v", err) + } + if m, ok := db.Lookup("F4BPO"); !ok || m.Lon <= 0 { // France 2°E → +2 + t.Errorf("France lon = %v, want positive (East)", m.Lon) + } + if m, ok := db.Lookup("K1ABC"); !ok || m.Lon >= 0 { // USA 92°W → ~-91.67 + t.Errorf("USA lon = %v, want negative (West)", m.Lon) + } +} + func TestLookup(t *testing.T) { db, err := Load(strings.NewReader(sampleCty)) if err != nil { diff --git a/internal/lookup/homecall_test.go b/internal/lookup/homecall_test.go new file mode 100644 index 0000000..853c231 --- /dev/null +++ b/internal/lookup/homecall_test.go @@ -0,0 +1,21 @@ +package lookup + +import "testing" + +func TestHomeCall(t *testing.T) { + cases := map[string]string{ + "JW/OR1A": "OR1A", // portable prefix → home call + "DL/F4NIE": "F4NIE", // prefix/home + "F4BPO/P": "F4BPO", // suffix dropped + "F4BPO/9": "F4BPO", // call-area digit dropped + "VP8/F4BPO": "F4BPO", // both have digits → longest wins + "MM/KA9P": "KA9P", // Scotland prefix + US home + "OH2BH": "OH2BH", // no slash → unchanged + "3D2/RW3RN": "RW3RN", + } + for in, want := range cases { + if got := homeCall(in); got != want { + t.Errorf("homeCall(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/lookup/lookup.go b/internal/lookup/lookup.go index 883f82d..d404f3d 100644 --- a/internal/lookup/lookup.go +++ b/internal/lookup/lookup.go @@ -125,6 +125,32 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { lastErr = fmt.Errorf("%s: %w", p.Name(), err) } + // Portable / slashed call not found under its full form: the operator's + // record lives under the HOME call (JW/OR1A → OR1A, DL/F4NIE → F4NIE). Look + // THAT up for the name/QTH/QSL info, then overwrite the location-determining + // fields with the SLASHED call's entity (JW = Svalbard, not OR1A's Belgium). + if home := homeCall(call); home != "" && home != call { + for _, p := range providers { + r, err := p.Lookup(ctx, home) + if err != nil { + continue + } + r.Callsign = call + r.Source = p.Name() + r.FetchedAt = time.Now().UTC() + // The home record's location is the operator's HOME, not where they + // are portable now — clear it so cty.dat fills the real entity. + r.Country, r.Continent = "", "" + r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0 + r.Lat, r.Lon = 0, 0 + r.Grid, r.State, r.County = "", "", "" + fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call + normalizeNames(&r) + _ = m.cache.Put(ctx, r) + return r, nil + } + } + // All providers exhausted (not-found or errored). Try the cty.dat // resolver as a last resort — at least we can hand back country/zones // even for unknown callsigns. Not cached: a "cty.dat-only" result @@ -149,6 +175,29 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) { return Result{}, lastErr } +// homeCall extracts the operator's home callsign from a slashed/portable call +// so its provider record (name/QTH/QSL) can be fetched when the full form isn't +// registered: JW/OR1A → OR1A, DL/F4NIE → F4NIE, F4BPO/P → F4BPO, VP8/F4BPO → +// F4BPO. The home call is the "/"-part that looks like a real callsign (has a +// digit, ≥3 chars); the longest such part wins (handles PREFIX/HOMECALL where +// both could qualify, e.g. VP8/F4BPO). Returns "" if none qualifies. +func homeCall(call string) string { + if !strings.ContainsRune(call, '/') { + return call + } + best := "" + for _, p := range strings.Split(call, "/") { + p = strings.TrimSpace(p) + if len(p) < 3 || !strings.ContainsAny(p, "0123456789") { + continue // a prefix (JW, DL) or a suffix (P, M, MM, QRP, a digit) + } + if len(p) > len(best) { + best = p + } + } + return best +} + // normalizeNames title-cases the human-readable text fields so a QRZ/HamQTH // reply in ALL CAPS ("NOEL CHENAVARD", "VETRAZ-MONTHOUX") is stored and shown // consistently ("Noel Chenavard", "Vetraz-Monthoux"). State/zones/grid are