Address lookup
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Address points represent mapped address nodes or the mean of a way's vertices.
|
||||
type addressPoint struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
type addressCell struct{ lat, lon int }
|
||||
type addressIndex struct {
|
||||
cells map[addressCell][]addressPoint
|
||||
count int
|
||||
}
|
||||
|
||||
const localAddressRange = 5000.0
|
||||
|
||||
func cellFor(lat, lon float64) addressCell {
|
||||
return addressCell{int(math.Floor(lat * 100)), int(math.Floor(lon * 100))}
|
||||
}
|
||||
func validCoordinate(lat, lon float64) bool {
|
||||
return !math.IsNaN(lat) && !math.IsNaN(lon) && !math.IsInf(lat, 0) && !math.IsInf(lon, 0) && math.Abs(lat) <= 90 && math.Abs(lon) <= 180
|
||||
}
|
||||
func loadAddresses(path string) (*addressIndex, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
var points []addressPoint
|
||||
if err = json.NewDecoder(f).Decode(&points); err != nil {
|
||||
return nil, fmt.Errorf("read address data: %w", err)
|
||||
}
|
||||
idx := &addressIndex{cells: make(map[addressCell][]addressPoint)}
|
||||
for _, p := range points {
|
||||
if !validCoordinate(p.Lat, p.Lon) || strings.TrimSpace(p.Label) == "" {
|
||||
return nil, fmt.Errorf("invalid address record")
|
||||
}
|
||||
cell := cellFor(p.Lat, p.Lon)
|
||||
idx.cells[cell] = append(idx.cells[cell], p)
|
||||
idx.count++
|
||||
}
|
||||
if idx.count == 0 {
|
||||
return nil, fmt.Errorf("address data contains no addresses")
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
func (idx *addressIndex) view(lat, lon float64) addressResult {
|
||||
if !validCoordinate(lat, lon) {
|
||||
return addressResult{Status: "unavailable"}
|
||||
}
|
||||
// An oversized geographic bounding box encloses the 5 km search circle.
|
||||
// This index is intended for Mississippi, away from the poles/dateline.
|
||||
dlat := localAddressRange / 110000
|
||||
dlon := dlat / math.Max(0.01, math.Cos((math.Abs(lat)+dlat)*math.Pi/180))
|
||||
lo, hi := cellFor(lat-dlat, lon-dlon), cellFor(lat+dlat, lon+dlon)
|
||||
best := localAddressRange
|
||||
var found *addressPoint
|
||||
for y := lo.lat; y <= hi.lat; y++ {
|
||||
for x := lo.lon; x <= hi.lon; x++ {
|
||||
for _, p := range idx.cells[addressCell{y, x}] {
|
||||
distance := distanceMeters(lat, lon, p.Lat, p.Lon)
|
||||
if distance <= best {
|
||||
copy := p
|
||||
found = ©
|
||||
best = distance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return addressResult{Status: "unavailable"}
|
||||
}
|
||||
return addressResult{Status: "ready", Address: &nearbyAddress{Label: found.Label, DistanceMeters: best, Attribution: "© OpenStreetMap contributors · local address data"}}
|
||||
}
|
||||
|
||||
type osmTag struct {
|
||||
Key string `xml:"k,attr"`
|
||||
Value string `xml:"v,attr"`
|
||||
}
|
||||
type osmNode struct {
|
||||
ID int64 `xml:"id,attr"`
|
||||
Lat float64 `xml:"lat,attr"`
|
||||
Lon float64 `xml:"lon,attr"`
|
||||
Tags []osmTag `xml:"tag"`
|
||||
}
|
||||
type osmWay struct {
|
||||
Refs []struct {
|
||||
ID int64 `xml:"ref,attr"`
|
||||
} `xml:"nd"`
|
||||
Tags []osmTag `xml:"tag"`
|
||||
}
|
||||
|
||||
func addressLabel(tags []osmTag) string {
|
||||
values := make(map[string]string)
|
||||
for _, tag := range tags {
|
||||
values[tag.Key] = strings.TrimSpace(tag.Value)
|
||||
}
|
||||
street := values["addr:street"]
|
||||
if street == "" {
|
||||
street = values["addr:place"]
|
||||
}
|
||||
if values["addr:housenumber"] == "" || street == "" {
|
||||
return ""
|
||||
}
|
||||
parts := []string{values["addr:housenumber"] + " " + street}
|
||||
for _, k := range []string{"addr:unit", "addr:city", "addr:state", "addr:postcode"} {
|
||||
if values[k] != "" {
|
||||
parts = append(parts, values[k])
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// Import node/way addresses from node-before-way OSM XML. Osmium's default
|
||||
// tags-filter output includes the referenced nodes needed for way coordinates.
|
||||
func importAddresses(input io.Reader) ([]addressPoint, error) {
|
||||
decoder := xml.NewDecoder(input)
|
||||
nodes := make(map[int64]addressPoint)
|
||||
var points []addressPoint
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch start.Name.Local {
|
||||
case "node":
|
||||
var n osmNode
|
||||
if err = decoder.DecodeElement(&n, &start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !validCoordinate(n.Lat, n.Lon) {
|
||||
continue
|
||||
}
|
||||
p := addressPoint{Lat: n.Lat, Lon: n.Lon, Label: addressLabel(n.Tags)}
|
||||
nodes[n.ID] = p
|
||||
if p.Label != "" {
|
||||
points = append(points, p)
|
||||
}
|
||||
case "way":
|
||||
var w osmWay
|
||||
if err = decoder.DecodeElement(&w, &start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
label := addressLabel(w.Tags)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
refs := w.Refs
|
||||
if len(refs) > 1 && refs[0].ID == refs[len(refs)-1].ID {
|
||||
refs = refs[:len(refs)-1]
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
var lat, lon float64
|
||||
complete := true
|
||||
for _, ref := range refs {
|
||||
p, ok := nodes[ref.ID]
|
||||
if !ok {
|
||||
complete = false
|
||||
break
|
||||
}
|
||||
lat += p.Lat
|
||||
lon += p.Lon
|
||||
}
|
||||
if complete {
|
||||
points = append(points, addressPoint{Lat: lat / float64(len(refs)), Lon: lon / float64(len(refs)), Label: label})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return nil, fmt.Errorf("no usable node/way addresses found in OSM XML")
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func buildAddresses(inputPath, outputPath string) (int, error) {
|
||||
f, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
points, err := importAddresses(f)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(outputPath), ".addresses-*.json")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
err = json.NewEncoder(tmp).Encode(points)
|
||||
closeErr := tmp.Close()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return 0, closeErr
|
||||
}
|
||||
if err = os.Rename(tmp.Name(), outputPath); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(points), nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"macker/datapoint"
|
||||
)
|
||||
|
||||
const addressFixture = `<osm version="0.6">
|
||||
<node id="1" lat="32.3" lon="-90.2"><tag k="addr:housenumber" v="100"/><tag k="addr:street" v="Main Street"/><tag k="addr:city" v="Jackson"/></node>
|
||||
<node id="2" lat="32.31" lon="-90.2"/>
|
||||
<node id="3" lat="32.31" lon="-90.19"/>
|
||||
<node id="4" lat="32.32" lon="-90.19"/>
|
||||
<node id="5" lat="32.32" lon="-90.2"/>
|
||||
<node id="6" lat="31" lon="-90"><tag k="addr:street" v="Missing number"/></node>
|
||||
<way id="10"><nd ref="2"/><nd ref="3"/><nd ref="4"/><nd ref="5"/><nd ref="2"/><tag k="addr:housenumber" v="200"/><tag k="addr:street" v="Oak Street"/></way>
|
||||
<way id="11"><nd ref="999"/><tag k="addr:housenumber" v="300"/><tag k="addr:street" v="Missing geometry"/></way>
|
||||
</osm>`
|
||||
|
||||
func TestImportLocalAddresses(t *testing.T) {
|
||||
points, err := importAddresses(strings.NewReader(addressFixture))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(points) != 2 || points[0].Label != "100 Main Street, Jackson" || points[1].Label != "200 Oak Street" {
|
||||
t.Fatalf("bad addresses: %+v", points)
|
||||
}
|
||||
if math.Abs(points[1].Lat-32.315) > 1e-8 || math.Abs(points[1].Lon-(-90.195)) > 1e-8 {
|
||||
t.Fatalf("incorrect closed-way position: %+v", points[1])
|
||||
}
|
||||
for _, input := range []string{"<osm><node", "<osm></osm>"} {
|
||||
if _, err = importAddresses(strings.NewReader(input)); err == nil {
|
||||
t.Fatal("accepted invalid/empty import")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAddressBuildLoadAndAPI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source.osm")
|
||||
target := filepath.Join(dir, "data", "addresses.json")
|
||||
if err := os.WriteFile(source, []byte(addressFixture), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count, err := buildAddresses(source, target); err != nil || count != 2 {
|
||||
t.Fatalf("import: %d %v", count, err)
|
||||
}
|
||||
index, err := loadAddresses(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := &tracker{addresses: index}
|
||||
tr.accept([]datapoint.DataPoint{{Lat: 32.3, Long: -90.2}}, time.Now())
|
||||
rr := httptest.NewRecorder()
|
||||
tr.handler().ServeHTTP(rr, httptest.NewRequest("GET", "/api/location", nil))
|
||||
var got location
|
||||
if err = json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Nearby.Status != "ready" || got.Nearby.Address.Label != "100 Main Street, Jackson" || got.Nearby.Address.DistanceMeters != 0 {
|
||||
t.Fatalf("bad response: %s", rr.Body.String())
|
||||
}
|
||||
if index.view(30.4, -89.1).Status != "unavailable" {
|
||||
t.Fatal("returned distant address outside search radius")
|
||||
}
|
||||
// A failed refresh must preserve the last usable database.
|
||||
if err = os.WriteFile(source, []byte("<osm>"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = buildAddresses(source, target); err == nil {
|
||||
t.Fatal("bad refresh accepted")
|
||||
}
|
||||
if _, err = loadAddresses(target); err != nil {
|
||||
t.Fatalf("old index lost after failed refresh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAddressIndexMatchesFullScan(t *testing.T) {
|
||||
random := rand.New(rand.NewSource(42))
|
||||
idx := &addressIndex{cells: make(map[addressCell][]addressPoint)}
|
||||
var all []addressPoint
|
||||
for i := 0; i < 2000; i++ {
|
||||
p := addressPoint{Lat: 30.2 + random.Float64()*2.3, Lon: -91.6 + random.Float64()*3.3, Label: "Test address"}
|
||||
all = append(all, p)
|
||||
c := cellFor(p.Lat, p.Lon)
|
||||
idx.cells[c] = append(idx.cells[c], p)
|
||||
}
|
||||
for i := 0; i < 300; i++ {
|
||||
lat, lon := 30.2+random.Float64()*2.3, -91.6+random.Float64()*3.3
|
||||
best := math.Inf(1)
|
||||
for _, p := range all {
|
||||
best = math.Min(best, distanceMeters(lat, lon, p.Lat, p.Lon))
|
||||
}
|
||||
got := idx.view(lat, lon)
|
||||
if best > localAddressRange {
|
||||
if got.Status != "unavailable" {
|
||||
t.Fatal("range limit ignored")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got.Address == nil || math.Abs(got.Address.DistanceMeters-best) > 0.001 {
|
||||
t.Fatalf("index missed nearest address: %+v; want %f", got, best)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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 = ©
|
||||
}
|
||||
t.mu.RUnlock()
|
||||
if current != nil && now.Sub(current.UpdatedAt) <= 30*time.Second {
|
||||
t.geocoder.update(ctx, current.Point.Lat, current.Point.Long, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"macker/datapoint"
|
||||
)
|
||||
|
||||
type transportFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func testGeocoder(t *testing.T, response string, status int) (*geocoder, *int) {
|
||||
t.Helper()
|
||||
g, err := newGeocoder("https://example.com/reverse", "secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
g.client = &http.Client{Transport: transportFunc(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if r.Header.Get("Authorization") != "Bearer secret" || r.URL.Query().Get("api_key") != "" {
|
||||
t.Error("key must stay in server-side Authorization header")
|
||||
}
|
||||
if r.URL.Query().Get("format") != "jsonv2" || r.URL.Query().Get("lat") == "" || r.URL.Query().Get("lon") == "" {
|
||||
t.Error("missing reverse geocoding parameters")
|
||||
}
|
||||
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(response)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
return g, &calls
|
||||
}
|
||||
|
||||
func TestGeocodeCacheDistanceAndRateLimit(t *testing.T) {
|
||||
g, calls := testGeocoder(t, `{"lat":"41.8001","lon":"-87.6","display_name":"123 Main Street"}`, 200)
|
||||
now := time.Now().UTC()
|
||||
g.update(context.Background(), 41.8, -87.6, now)
|
||||
v := g.view(41.8, -87.6, now)
|
||||
if v.Status != "ready" || v.Address == nil || v.Address.Label != "123 Main Street" || math.Abs(v.Address.DistanceMeters-11.12) > 0.1 {
|
||||
t.Fatalf("bad address: %+v", v)
|
||||
}
|
||||
// A nearby fix reuses the cached address but computes its own distance.
|
||||
g.update(context.Background(), 41.80005, -87.6, now.Add(time.Minute))
|
||||
moved := g.view(41.80005, -87.6, now.Add(time.Minute))
|
||||
if *calls != 1 || moved.Address.DistanceMeters >= v.Address.DistanceMeters {
|
||||
t.Fatal("cache not reused or distance not updated")
|
||||
}
|
||||
// A distant fix must not display an address from the previous area.
|
||||
if g.view(42, -87.6, now).Status != "pending" {
|
||||
t.Fatal("old address shown for distant fix")
|
||||
}
|
||||
g.update(context.Background(), 42, -87.6, now.Add(time.Second))
|
||||
if *calls != 1 {
|
||||
t.Fatal("rate limit bypassed")
|
||||
}
|
||||
g.update(context.Background(), 42, -87.6, now.Add(31*time.Second))
|
||||
if *calls != 2 {
|
||||
t.Fatal("moved fix did not trigger new lookup")
|
||||
}
|
||||
if g.view(41.8, -87.6, now.Add(6*time.Minute)).Address != nil {
|
||||
t.Fatal("expired cache used")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeocodeFailureAndRetry(t *testing.T) {
|
||||
for name, response := range map[string]string{"no match": `{"error":"Unable to geocode"}`, "invalid coords": `{"lat":"NaN","lon":"0","display_name":"bad"}`, "malformed": "not JSON", "oversized": strings.Repeat("x", (1<<20)+1)} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
g, calls := testGeocoder(t, response, 200)
|
||||
now := time.Now()
|
||||
g.update(context.Background(), 1, 2, now)
|
||||
if g.view(1, 2, now).Status != "unavailable" {
|
||||
t.Fatal("bad response accepted")
|
||||
}
|
||||
g.update(context.Background(), 1, 2, now.Add(time.Second))
|
||||
if *calls != 1 {
|
||||
t.Fatal("failure retried too soon")
|
||||
}
|
||||
g.update(context.Background(), 1, 2, now.Add(31*time.Second))
|
||||
if *calls != 2 {
|
||||
t.Fatal("failure not retried")
|
||||
}
|
||||
})
|
||||
}
|
||||
g, _ := testGeocoder(t, `{}`, 429)
|
||||
g.update(context.Background(), 1, 2, time.Now())
|
||||
if g.view(1, 2, time.Now()).Status != "unavailable" {
|
||||
t.Fatal("HTTP error accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeocoderConfigurationAndAPI(t *testing.T) {
|
||||
for _, endpoint := range []string{"file:///tmp/address", "https://nominatim.openstreetmap.org/reverse", "https://user:pass@example.com"} {
|
||||
if _, err := newGeocoder(endpoint, ""); err == nil {
|
||||
t.Fatalf("accepted endpoint %q", endpoint)
|
||||
}
|
||||
}
|
||||
disabled, err := newGeocoder("", "")
|
||||
if err != nil || disabled != nil || disabled.view(0, 0, time.Now()).Status != "disabled" {
|
||||
t.Fatal("bad disabled behavior")
|
||||
}
|
||||
g, _ := testGeocoder(t, `{"lat":"41.8","lon":"-87.6","display_name":"123 Main Street"}`, 200)
|
||||
now := time.Now()
|
||||
g.update(context.Background(), 41.8, -87.6, now)
|
||||
tr := &tracker{geocoder: g}
|
||||
tr.accept([]datapoint.DataPoint{{Lat: 41.8, Long: -87.6}}, now)
|
||||
rr := httptest.NewRecorder()
|
||||
tr.handler().ServeHTTP(rr, httptest.NewRequest("GET", "/api/location", nil))
|
||||
if !strings.Contains(rr.Body.String(), `"label":"123 Main Street"`) || strings.Contains(rr.Body.String(), "secret") {
|
||||
t.Fatalf("bad API response: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDistanceMeters(t *testing.T) {
|
||||
if distanceMeters(0, 0, 0, 0) != 0 {
|
||||
t.Fatal("identical coordinates have a distance")
|
||||
}
|
||||
if got := distanceMeters(0, 0, 0, 1); math.Abs(got-111195) > 1 {
|
||||
t.Fatalf("unexpected one degree distance: %f", got)
|
||||
}
|
||||
if got := distanceMeters(0, 179.999, 0, -179.999); math.Abs(got-222.39) > 1 {
|
||||
t.Fatalf("bad antimeridian distance: %f", got)
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -5,12 +5,12 @@
|
||||
<title>Macker · Work car</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#f3f5f2;color:#182d26;font:16px system-ui,sans-serif}header{padding:24px 5vw;display:flex;justify-content:space-between;align-items:center}h1{font-size:24px;margin:0}header p{margin:4px 0;color:#607269}main{margin:0 5vw 28px}.stats{display:flex;gap:16px;margin-bottom:20px}.card{background:white;border:1px solid #dce4dd;border-radius:12px;padding:20px;flex:1}.label{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#607269}.value{display:block;font-size:32px;margin:8px 0}small{color:#607269}#map{height:65vh;min-height:320px;border-radius:14px;border:1px solid #cbd8cd}button{background:#1c593f;color:white;border:0;border-radius:8px;padding:12px;cursor:pointer}#status{margin:12px 0;min-height:24px}@media(max-width:540px){.stats{flex-direction:column;gap:10px}.card{padding:14px}.value{font-size:26px}header{gap:12px}#map{height:50vh}}
|
||||
*{box-sizing:border-box}body{margin:0;background:#f3f5f2;color:#182d26;font:16px system-ui,sans-serif}header{padding:24px 5vw;display:flex;justify-content:space-between;align-items:center}h1{font-size:24px;margin:0}header p{margin:4px 0;color:#607269}main{margin:0 5vw 28px}.stats{display:flex;gap:16px;margin-bottom:20px}.card{background:white;border:1px solid #dce4dd;border-radius:12px;padding:20px;flex:1}.label{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#607269}.value{display:block;font-size:32px;margin:8px 0}small{color:#607269}#address{font-size:20px;overflow-wrap:anywhere}.stats{flex-wrap:wrap}.card{min-width:220px}#map{height:65vh;min-height:320px;border-radius:14px;border:1px solid #cbd8cd}button{background:#1c593f;color:white;border:0;border-radius:8px;padding:12px;cursor:pointer}#status{margin:12px 0;min-height:24px}@media(max-width:540px){.stats{flex-direction:column;gap:10px}.card{padding:14px}.value{font-size:26px}header{gap:12px}#address{font-size:20px;overflow-wrap:anywhere}.stats{flex-wrap:wrap}.card{min-width:220px}#map{height:50vh}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><div><h1>Macker</h1><p>Work car · Live location</p></div><button id="follow" type="button">Follow car: on</button></header>
|
||||
<main><div class="stats"><section class="card"><span class="label">Current speed</span><strong class="value" id="speed">—</strong><small id="metric">Waiting for GPS</small></section><section class="card"><span class="label">Last update</span><strong class="value" id="age">—</strong><small id="time">No location received yet</small></section></div><p id="status" role="status">Waiting for the car’s first GPS fix…</p><p id="uncertainty"><small>GPS uncertainty unavailable</small></p><div id="map" aria-label="Car location map with GPS uncertainty circle"></div></main>
|
||||
<main><div class="stats"><section class="card"><span class="label">Current speed</span><strong class="value" id="speed">—</strong><small id="metric">Waiting for GPS</small></section><section class="card"><span class="label">Last update</span><strong class="value" id="age">—</strong><small id="time">No location received yet</small></section><section class="card"><span class="label">Nearby address</span><strong class="value" id="address">—</strong><small id="address-distance">Waiting for GPS</small><br><small id="address-source"></small></section></div><p id="status" role="status">Waiting for the car’s first GPS fix…</p><p id="uncertainty"><small>GPS uncertainty unavailable</small></p><div id="map" aria-label="Car location map with GPS uncertainty circle"></div></main>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
const el=id=>document.getElementById(id);
|
||||
@@ -36,10 +36,24 @@ function updateUncertainty(point,pos){
|
||||
uncertaintyCircle=L.circle(pos,{radius,color:'#287a57',weight:2,fillColor:'#409b73',fillOpacity:0.16,interactive:false}).addTo(map);
|
||||
}else{uncertaintyCircle.setLatLng(pos).setRadius(radius);}
|
||||
}
|
||||
function renderAddress(nearby,stale){
|
||||
const address=nearby?.address;
|
||||
el('address').textContent=address?.label||'—';
|
||||
el('address-source').textContent=address?.attribution||'';
|
||||
if(address){
|
||||
const m=address.distance_meters;
|
||||
const distance=m<1000?`${Math.round(m)} m`:`${(m/1000).toFixed(1)} km`;
|
||||
el('address-distance').textContent=`Approximately ${distance} away · straight-line${stale?' · last known fix':''}`;
|
||||
}else{
|
||||
const messages={disabled:'Address lookup is not configured',pending:'Looking up nearby address…',unavailable:'No nearby mapped address available'};
|
||||
el('address-distance').textContent=messages[nearby?.status]||'Address unavailable';
|
||||
}
|
||||
}
|
||||
function render(){
|
||||
if(!latest){el('status').textContent=failed?'Cannot reach server. Retrying…':'Waiting for the car’s first GPS fix…';return;}
|
||||
const seconds=Math.max(0,Math.floor((Date.now()-Date.parse(latest.updated_at))/1000));
|
||||
const stale=seconds>30,p=latest.point;
|
||||
renderAddress(latest.nearby,stale||failed);
|
||||
el('age').textContent=seconds<60?`${seconds} s ago`:seconds<3600?`${Math.floor(seconds/60)} min ago`:`${Math.floor(seconds/3600)} h ago`;
|
||||
el('time').textContent=new Date(latest.updated_at).toLocaleString();
|
||||
el('speed').textContent=stale||p.Speed==null?'—':`${(p.Speed*2.236936).toFixed(1)} mph`;
|
||||
|
||||
+57
-5
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -23,11 +25,14 @@ type location struct {
|
||||
Point datapoint.DataPoint `json:"point"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Nearby addressResult `json:"nearby"`
|
||||
}
|
||||
|
||||
type tracker struct {
|
||||
mu sync.RWMutex
|
||||
latest *location
|
||||
mu sync.RWMutex
|
||||
latest *location
|
||||
geocoder *geocoder
|
||||
addresses *addressIndex
|
||||
}
|
||||
|
||||
func (t *tracker) accept(points []datapoint.DataPoint, now time.Time) {
|
||||
@@ -76,8 +81,19 @@ func (t *tracker) handler() http.Handler {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
_ = json.NewEncoder(w).Encode(t.latest)
|
||||
if t.latest == nil {
|
||||
t.mu.RUnlock()
|
||||
_ = json.NewEncoder(w).Encode(nil)
|
||||
return
|
||||
}
|
||||
snapshot := *t.latest
|
||||
t.mu.RUnlock()
|
||||
if t.addresses != nil {
|
||||
snapshot.Nearby = t.addresses.view(snapshot.Point.Lat, snapshot.Point.Long)
|
||||
} else {
|
||||
snapshot.Nearby = t.geocoder.view(snapshot.Point.Lat, snapshot.Point.Long, time.Now())
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(snapshot)
|
||||
})
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -89,13 +105,49 @@ func (t *tracker) handler() http.Handler {
|
||||
func main() {
|
||||
ingest := flag.String("ingest", ":2948", "TCP address for car GPS batches")
|
||||
web := flag.String("http", ":8080", "HTTP address for the map")
|
||||
geocodeURL := flag.String("geocoder", os.Getenv("MACKER_GEOCODER_URL"), "Nominatim-compatible reverse geocoding endpoint (empty disables address lookups)")
|
||||
addressFile := flag.String("addresses", "data/mississippi-addresses.json", "local OSM address index (takes precedence over geocoder)")
|
||||
importOSM := flag.String("import-osm", "", "import address nodes/ways from OSM XML into -addresses, then exit")
|
||||
flag.Parse()
|
||||
if *importOSM != "" {
|
||||
count, err := buildAddresses(*importOSM, *addressFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Saved %d local addresses to %s", count, *addressFile)
|
||||
return
|
||||
}
|
||||
var local *addressIndex
|
||||
if *addressFile != "" {
|
||||
var err error
|
||||
local, err = loadAddresses(*addressFile)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("Local address data missing: run scripts/prepare-addresses.sh, then restart")
|
||||
}
|
||||
if local != nil {
|
||||
log.Printf("Loaded %d local addresses", local.count)
|
||||
}
|
||||
}
|
||||
var gc *geocoder
|
||||
var err error
|
||||
if local == nil {
|
||||
gc, err = newGeocoder(*geocodeURL, os.Getenv("MACKER_GEOCODER_KEY"))
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", *ingest)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
t := &tracker{}
|
||||
t := &tracker{geocoder: gc, addresses: local}
|
||||
if gc != nil {
|
||||
go t.runGeocoder(context.Background())
|
||||
}
|
||||
go func() {
|
||||
// Bound concurrent decoders and their memory usage.
|
||||
slots := make(chan struct{}, 32)
|
||||
|
||||
Reference in New Issue
Block a user