Files

226 lines
5.7 KiB
Go
Raw Permalink Normal View History

2026-10-28 14:04:39 -05:00
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
}