fix(webpub): the Distance column was empty for every QSO logged here

Nothing computes a distance when a QSO is logged. The DISTANCE field is only
ever filled by an ADIF import that carried one, so publishing it straight gave
an empty column to anyone whose log was made in OpsLog — which is everyone who
reported it.

It falls back to the two locators, which are on the QSO already. A stored
distance still wins: it came from the log that recorded the contact, which knew
more than two four-character squares do. No grids means an empty cell, not a
zero — an empty cell is honest, a zero is a claim.

Rounded to whole kilometres. The squares are tens of kilometres across and a
decimal would assert an accuracy nobody has.

The geometry moved to internal/geo on the way. It lived in package main, which
internal packages cannot import, so the PSK Reporter watcher already had its
maths injected from main and this would have been a third copy. A bearing that
disagrees with itself between two panels is a fault nobody reports, because each
screen looks perfectly plausible on its own.
This commit is contained in:
2026-08-11 19:41:47 +02:00
parent 102097c5c4
commit f7b9bfd0bc
5 changed files with 143 additions and 43 deletions
+9 -40
View File
@@ -40,6 +40,7 @@ import (
"hamlog/internal/dxcc"
"hamlog/internal/email"
"hamlog/internal/extsvc"
"hamlog/internal/geo"
"hamlog/internal/integrations/udp"
"hamlog/internal/lookup"
"hamlog/internal/lotwusers"
@@ -746,48 +747,16 @@ type App struct {
udpLastMode string
}
// gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the
// centre lat/lon in degrees. Returns ok=false on malformed input.
func gridToLatLon(grid string) (lat, lon float64, ok bool) {
g := strings.ToUpper(strings.TrimSpace(grid))
if len(g) < 4 {
return 0, 0, false
}
A := g[0] - 'A'
B := g[1] - 'A'
C := g[2] - '0'
D := g[3] - '0'
if A > 17 || B > 17 || C > 9 || D > 9 {
return 0, 0, false
}
lon = -180 + float64(A)*20 + float64(C)*2
lat = -90 + float64(B)*10 + float64(D)*1
if len(g) >= 6 {
E := g[4] - 'A'
F := g[5] - 'A'
if E <= 23 && F <= 23 {
lon += float64(E)*(5.0/60.0) + 2.5/60.0
lat += float64(F)*(2.5/60.0) + 1.25/60.0
return lat, lon, true
}
}
// 4-char locator: aim at the centre of the square.
lon += 1
lat += 0.5
return lat, lon, true
}
// gridToLatLon and haversineKm live in internal/geo, which the internal
// packages can import — package main cannot be imported by anything. These
// forward so there is exactly ONE implementation: the PSK Reporter watcher and
// the web publisher measure the same path the cluster does, and a bearing that
// disagrees with itself between two panels is a fault nobody reports, because
// each screen looks plausible alone.
func gridToLatLon(grid string) (lat, lon float64, ok bool) { return geo.GridToLatLon(grid) }
// haversineKm returns the great-circle distance between two lat/lon pairs
// in kilometres. Standard Haversine, mean Earth radius 6371 km.
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0
rad := math.Pi / 180.0
dLat := (lat2 - lat1) * rad
dLon := (lon2 - lon1) * rad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
return geo.HaversineKm(lat1, lon1, lat2, lon2)
}
// initialBearingDeg returns the initial great-circle bearing (azimuth) in