Address lookup

This commit is contained in:
beans
2026-10-28 14:04:39 -05:00
parent c4a7a6bcf4
commit 1627d60714
6 changed files with 716 additions and 7 deletions
+225
View File
@@ -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 = &copy
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
}
+113
View File
@@ -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)
}
}
}
+176
View File
@@ -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 = &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)
}
}
}
}
+129
View File
@@ -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
View File
@@ -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
View File
@@ -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)