This commit is contained in:
2026-06-07 12:50:04 +02:00
parent eb64b8f2f9
commit 9189f54df5
7 changed files with 126 additions and 9 deletions
+9 -2
View File
@@ -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 (
+12 -5
View File
@@ -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`;
+10
View File
@@ -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);
+9 -2
View File
@@ -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
}
}
+16
View File
@@ -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 {
+21
View File
@@ -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)
}
}
}
+49
View File
@@ -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