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
+69
View File
@@ -0,0 +1,69 @@
// Package geo is the one place that turns Maidenhead locators into positions,
// and positions into distances and bearings.
//
// It exists because there were about to be three copies. These functions lived
// in package main, which the internal packages cannot import, so the PSK
// Reporter watcher had its geometry injected from main and the web publisher
// was about to grow its own. A bearing that disagrees with itself between two
// panels is the kind of fault nobody reports, because each screen looks
// plausible on its own.
package geo
import (
"math"
"strings"
)
// GridToLatLon parses a Maidenhead locator (4 or 6 characters) and returns the
// centre of that square in degrees. 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-character locator: aim at the centre of the square.
lon += 1
lat += 0.5
return lat, lon, true
}
// HaversineKm returns the great-circle distance between two positions in
// kilometres. 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)
return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
// DistanceBetweenGrids is the distance in kilometres between two locators,
// ok=false when either cannot be parsed.
func DistanceBetweenGrids(a, b string) (km float64, ok bool) {
lat1, lon1, ok1 := GridToLatLon(a)
lat2, lon2, ok2 := GridToLatLon(b)
if !ok1 || !ok2 {
return 0, false
}
return HaversineKm(lat1, lon1, lat2, lon2), true
}