up
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user