Files
macker/server/geocode.go
T
2026-10-28 14:04:39 -05:00

177 lines
5.2 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const addressCacheRadius = 25.0 // Metres from the coordinates used for the lookup.
const addressCacheTTL = 5 * time.Minute
type nearbyAddress struct {
Label string `json:"label"`
DistanceMeters float64 `json:"distance_meters"`
Attribution string `json:"attribution"`
}
type addressResult struct {
Status string `json:"status"`
Address *nearbyAddress `json:"address,omitempty"`
}
type cachedAddress struct {
queryLat, queryLon, lat, lon float64
label string
at time.Time
}
type geocoder struct {
endpoint, key string
client *http.Client
mu sync.RWMutex
cache []cachedAddress
lastAttempt time.Time
failed bool
}
func newGeocoder(endpoint, key string) (*geocoder, error) {
if endpoint == "" {
return nil, nil
}
u, err := url.Parse(endpoint)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil {
return nil, fmt.Errorf("geocoder URL must be an HTTP(S) endpoint without embedded credentials")
}
if strings.EqualFold(u.Hostname(), "nominatim.openstreetmap.org") {
return nil, fmt.Errorf("use a tracking-compatible provider or your own Nominatim instance")
}
return &geocoder{endpoint: endpoint, key: key, client: &http.Client{Timeout: 8 * time.Second}}, nil
}
// Straight-line distance between WGS84 coordinates, in metres.
func distanceMeters(lat1, lon1, lat2, lon2 float64) float64 {
const rad = math.Pi / 180
a := math.Pow(math.Sin((lat2-lat1)*rad/2), 2) + math.Cos(lat1*rad)*math.Cos(lat2*rad)*math.Pow(math.Sin((lon2-lon1)*rad/2), 2)
return 6371000 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, a))))
}
func (g *geocoder) cached(lat, lon float64, now time.Time) *cachedAddress {
for i := len(g.cache) - 1; i >= 0; i-- {
c := &g.cache[i]
if now.Sub(c.at) < addressCacheTTL && distanceMeters(lat, lon, c.queryLat, c.queryLon) <= addressCacheRadius {
return c
}
}
return nil
}
func (g *geocoder) view(lat, lon float64, now time.Time) addressResult {
if g == nil {
return addressResult{Status: "disabled"}
}
g.mu.RLock()
defer g.mu.RUnlock()
if c := g.cached(lat, lon, now); c != nil {
return addressResult{Status: "ready", Address: &nearbyAddress{Label: c.label, DistanceMeters: distanceMeters(lat, lon, c.lat, c.lon), Attribution: "© OpenStreetMap contributors"}}
}
if g.failed {
return addressResult{Status: "unavailable"}
}
return addressResult{Status: "pending"}
}
func (g *geocoder) update(ctx context.Context, lat, lon float64, now time.Time) {
g.mu.Lock()
if g.cached(lat, lon, now) != nil || (!g.lastAttempt.IsZero() && now.Sub(g.lastAttempt) < 30*time.Second) {
g.mu.Unlock()
return
}
g.lastAttempt = now
g.mu.Unlock()
c, err := g.lookup(ctx, lat, lon)
g.mu.Lock()
defer g.mu.Unlock()
g.failed = err != nil
if err != nil {
return
} // Keep provider errors and credentials out of API responses and logs.
c.at = now
if len(g.cache) >= 128 {
g.cache = g.cache[1:]
}
g.cache = append(g.cache, c)
}
func (g *geocoder) lookup(ctx context.Context, lat, lon float64) (cachedAddress, error) {
u, _ := url.Parse(g.endpoint)
q := u.Query()
q.Set("lat", strconv.FormatFloat(lat, 'f', 7, 64))
q.Set("lon", strconv.FormatFloat(lon, 'f', 7, 64))
q.Set("format", "jsonv2")
q.Set("zoom", "18")
q.Set("addressdetails", "1")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return cachedAddress{}, err
}
req.Header.Set("User-Agent", "Macker/1.0")
if g.key != "" {
req.Header.Set("Authorization", "Bearer "+g.key)
}
res, err := g.client.Do(req)
if err != nil {
return cachedAddress{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return cachedAddress{}, fmt.Errorf("geocoder status %d", res.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20+1))
if err != nil {
return cachedAddress{}, err
}
if len(raw) > 1<<20 {
return cachedAddress{}, fmt.Errorf("geocoder response too large")
}
var v struct {
Lat string `json:"lat"`
Lon string `json:"lon"`
DisplayName string `json:"display_name"`
}
if err = json.Unmarshal(raw, &v); err != nil {
return cachedAddress{}, err
}
alat, e1 := strconv.ParseFloat(v.Lat, 64)
alon, e2 := strconv.ParseFloat(v.Lon, 64)
if e1 != nil || e2 != nil || math.IsNaN(alat) || math.IsNaN(alon) || math.IsInf(alat, 0) || math.IsInf(alon, 0) || math.Abs(alat) > 90 || math.Abs(alon) > 180 || strings.TrimSpace(v.DisplayName) == "" {
return cachedAddress{}, fmt.Errorf("no usable address")
}
return cachedAddress{queryLat: lat, queryLon: lon, lat: alat, lon: alon, label: v.DisplayName}, nil
}
func (t *tracker) runGeocoder(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
t.mu.RLock()
var current *location
if t.latest != nil {
copy := *t.latest
current = &copy
}
t.mu.RUnlock()
if current != nil && now.Sub(current.UpdatedAt) <= 30*time.Second {
t.geocoder.update(ctx, current.Point.Lat, current.Point.Long, now)
}
}
}
}