Add API for getting just the closest reverse geocoded answer

Because we don't care about anything that is nearby when the user clicks
on the map, we just want the closest thing.
This commit is contained in:
Eli Ribble 2026-04-27 19:38:17 +00:00
parent b92697b8c8
commit 96498c01bf
No known key found for this signature in database
6 changed files with 132 additions and 44 deletions

View file

@ -102,6 +102,7 @@ func AddRoutes(r *mux.Router) {
geocode := resource.Geocode(router) geocode := resource.Geocode(router)
r.Handle("/geocode/by-gid/{id:.*}", handlerJSON(geocode.ByGID)).Methods("GET") r.Handle("/geocode/by-gid/{id:.*}", handlerJSON(geocode.ByGID)).Methods("GET")
r.Handle("/geocode/reverse", handlerJSONPost(geocode.Reverse)).Methods("POST") r.Handle("/geocode/reverse", handlerJSONPost(geocode.Reverse)).Methods("POST")
r.Handle("/geocode/reverse/closest", handlerJSONPost(geocode.ReverseClosest)).Methods("POST")
r.Handle("/geocode/suggestion", handlerJSONSlice(geocode.SuggestionList)).Methods("GET") r.Handle("/geocode/suggestion", handlerJSONSlice(geocode.SuggestionList)).Methods("GET")
publicreport := resource.Publicreport(router) publicreport := resource.Publicreport(router)
r.Handle("/publicreport/{id}", handlerBasic(publicreport.ByID)).Methods("GET").Name("publicreport.ByIDGet") r.Handle("/publicreport/{id}", handlerBasic(publicreport.ByID)).Methods("GET").Name("publicreport.ByIDGet")

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"sort"
"time" "time"
"github.com/Gleipnir-Technology/bob" "github.com/Gleipnir-Technology/bob"
@ -79,7 +80,7 @@ func GeocodeRaw(ctx context.Context, org *models.Organization, address string) (
if err != nil { if err != nil {
return nil, fmt.Errorf("insert addresses: %w", err) return nil, fmt.Errorf("insert addresses: %w", err)
} }
return toGeocodeResult(*resp, address, addresses) return toGeocodeResult(resp.Features, address, addresses)
} }
func GeocodeStructured(ctx context.Context, org *models.Organization, a types.Address) (*GeocodeResult, error) { func GeocodeStructured(ctx context.Context, org *models.Organization, a types.Address) (*GeocodeResult, error) {
street := fmt.Sprintf("%s %s", a.Number, a.Street) street := fmt.Sprintf("%s %s", a.Number, a.Street)
@ -99,49 +100,7 @@ func GeocodeStructured(ctx context.Context, org *models.Organization, a types.Ad
if err != nil { if err != nil {
return nil, fmt.Errorf("insert addresses: %w", err) return nil, fmt.Errorf("insert addresses: %w", err)
} }
return toGeocodeResult(*resp, a.String(), addresses) return toGeocodeResult(resp.Features, a.String(), addresses)
}
func ReverseGeocode(ctx context.Context, location types.Location) (*GeocodeResult, error) {
req := stadia.RequestReverseGeocode{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
resp, err := client.ReverseGeocode(ctx, req)
if err != nil {
return nil, fmt.Errorf("client reverse geocode failure on %s: %w", location.String(), err)
}
addresses, err := insertAddresses(ctx, db.PGInstance.BobDB, resp.Features)
if err != nil {
return nil, fmt.Errorf("insert addresses: %w", err)
}
return toGeocodeResult(*resp, location.String(), addresses)
}
func toGeocodeResult(resp stadia.GeocodeResponse, address_msg string, addresses []types.Address) (*GeocodeResult, error) {
if len(resp.Features) < 1 {
return nil, fmt.Errorf("%s matched no locations", address_msg)
}
if len(addresses) < 1 {
return nil, fmt.Errorf("no addresses")
}
if len(resp.Features) > 1 {
if !allFeaturesIdenticalEnough(resp.Features) {
return nil, fmt.Errorf("%s matched more than one location, and they differ a lot", address_msg)
}
}
feature := resp.Features[0]
address := addresses[0]
if feature.Geometry.Type != "Point" {
return nil, fmt.Errorf("wrong type %s from %s", feature.Geometry.Type, address_msg)
}
cell, err := h3utils.GetCell(address.Location.Longitude, address.Location.Latitude, 15)
if err != nil {
return nil, fmt.Errorf("failed to convert lat %f lng %f to h3 cell", address.Location.Longitude, address.Location.Latitude)
}
return &GeocodeResult{
Address: address,
Cell: cell,
}, nil
} }
// Get the parcel for a given address, if one can be found // Get the parcel for a given address, if one can be found
@ -161,6 +120,71 @@ func GetParcel(ctx context.Context, txn bob.Executor, a types.Address) (*models.
} }
return result, nil return result, nil
} }
func ReverseGeocode(ctx context.Context, location types.Location) (*GeocodeResult, error) {
req := stadia.RequestReverseGeocode{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
resp, err := client.ReverseGeocode(ctx, req)
if err != nil {
return nil, fmt.Errorf("client reverse geocode failure on %s: %w", location.String(), err)
}
addresses, err := insertAddresses(ctx, db.PGInstance.BobDB, resp.Features)
if err != nil {
return nil, fmt.Errorf("insert addresses: %w", err)
}
return toGeocodeResult(resp.Features, location.String(), addresses)
}
func ReverseGeocodeClosest(ctx context.Context, location types.Location) (*GeocodeResult, error) {
req := stadia.RequestReverseGeocode{
Latitude: location.Latitude,
Longitude: location.Longitude,
}
resp, err := client.ReverseGeocode(ctx, req)
if err != nil {
return nil, fmt.Errorf("client reverse geocode failure on %s: %w", location.String(), err)
}
addresses, err := insertAddresses(ctx, db.PGInstance.BobDB, resp.Features)
if err != nil {
return nil, fmt.Errorf("insert addresses: %w", err)
}
/*
sorter := SortAddressByDistance{
Addresses: addresses,
Location: location,
}
*/
sort.Sort(SortFeaturesByDistance(resp.Features))
return toGeocodeResult(resp.Features[:1], location.String(), addresses[:1])
}
func toGeocodeResult(features []stadia.GeocodeFeature, address_msg string, addresses []types.Address) (*GeocodeResult, error) {
if len(features) < 1 {
return nil, fmt.Errorf("%s matched no locations", address_msg)
}
if len(addresses) < 1 {
return nil, fmt.Errorf("no addresses")
}
if len(features) > 1 {
if !allFeaturesIdenticalEnough(features) {
return nil, fmt.Errorf("%s matched more than one location, and they differ a lot", address_msg)
}
}
feature := features[0]
address := addresses[0]
if feature.Geometry.Type != "Point" {
return nil, fmt.Errorf("wrong type %s from %s", feature.Geometry.Type, address_msg)
}
cell, err := h3utils.GetCell(address.Location.Longitude, address.Location.Latitude, 15)
if err != nil {
return nil, fmt.Errorf("failed to convert lat %f lng %f to h3 cell", address.Location.Longitude, address.Location.Latitude)
}
return &GeocodeResult{
Address: address,
Cell: cell,
}, nil
}
func allFeaturesIdenticalEnough(features []stadia.GeocodeFeature) bool { func allFeaturesIdenticalEnough(features []stadia.GeocodeFeature) bool {
if len(features) < 2 { if len(features) < 2 {
return true return true

49
platform/geocode/sort.go Normal file
View file

@ -0,0 +1,49 @@
package geocode
import (
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
"github.com/Gleipnir-Technology/nidus-sync/stadia"
)
type SortAddressByDistance struct {
Addresses []types.Address
Location types.Location
}
func (s SortAddressByDistance) Len() int { return len(s.Addresses) }
func (s SortAddressByDistance) Swap(i, j int) {
s.Addresses[i], s.Addresses[j] = s.Addresses[j], s.Addresses[i]
}
func (s SortAddressByDistance) Less(i, j int) bool {
ai := s.Addresses[i]
aj := s.Addresses[j]
if ai.Location == nil || (ai.Location.Latitude == 0 && ai.Location.Longitude == 0) {
if aj.Location == nil || (aj.Location.Latitude == 0 && aj.Location.Longitude == 0) {
return ai.Raw > aj.Raw
}
return false
} else if aj.Location == nil || (aj.Location.Latitude == 0 && aj.Location.Longitude == 0) {
return true
}
di := types.LocationDistance(s.Location, *ai.Location)
dj := types.LocationDistance(s.Location, *ai.Location)
return di < dj
}
type SortFeaturesByDistance []stadia.GeocodeFeature
func (s SortFeaturesByDistance) Len() int { return len(s) }
func (s SortFeaturesByDistance) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s SortFeaturesByDistance) Less(i, j int) bool {
fi := s[i].Properties.Distance
fj := s[j].Properties.Distance
if fi == nil {
if fj == nil {
return s[i].Properties.GID < s[j].Properties.GID
}
return false
} else if fj == nil {
return true
}
return *fi < *fj
}

View file

@ -2,6 +2,7 @@ package types
import ( import (
"fmt" "fmt"
"math"
"github.com/Gleipnir-Technology/nidus-sync/db/models" "github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/h3utils" "github.com/Gleipnir-Technology/nidus-sync/h3utils"
@ -36,3 +37,8 @@ func (l Location) GeometryQuery() (string, error) {
func LocationFromFS(pl *models.FieldseekerPointlocation) Location { func LocationFromFS(pl *models.FieldseekerPointlocation) Location {
return Location{} return Location{}
} }
func LocationDistance(l1 Location, l2 Location) float64 {
lat_delta := l1.Latitude - l2.Latitude
lng_delta := l1.Longitude - l2.Longitude
return math.Sqrt((lat_delta * lat_delta) + (lng_delta * lng_delta))
}

View file

@ -59,6 +59,13 @@ func (res *geocodeR) Reverse(ctx context.Context, r *http.Request, location type
} }
return newGeocode(g), nil return newGeocode(g), nil
} }
func (res *geocodeR) ReverseClosest(ctx context.Context, r *http.Request, location types.Location) (*geocode, *nhttp.ErrorWithStatus) {
g, err := ngeocode.ReverseGeocodeClosest(ctx, location)
if err != nil {
return nil, nhttp.NewError("reverse closest: %w", err)
}
return newGeocode(g), nil
}
func (res *geocodeR) SuggestionList(ctx context.Context, r *http.Request, query QueryParams) ([]*geocodeSuggestion, *nhttp.ErrorWithStatus) { func (res *geocodeR) SuggestionList(ctx context.Context, r *http.Request, query QueryParams) ([]*geocodeSuggestion, *nhttp.ErrorWithStatus) {
if query.Query == nil { if query.Query == nil {
return nil, nhttp.NewBadRequest("you must include a query") return nil, nhttp.NewBadRequest("you must include a query")

View file

@ -123,6 +123,7 @@ type GeocodeProperties struct {
County string `json:"county"` // "Tulare County" County string `json:"county"` // "Tulare County"
CountyA string `json:"county_a"` // 'TL' CountyA string `json:"county_a"` // 'TL'
CountyGID string `json:"county_gid"` // 'whosonfirst:county:102082895' CountyGID string `json:"county_gid"` // 'whosonfirst:county:102082895'
Distance *float64 `json:"distance"` //
FormattedAddressLine string `json:"formatted_address_line"` // '123 Main Street, Riverton, Utah 84065, United States of America' FormattedAddressLine string `json:"formatted_address_line"` // '123 Main Street, Riverton, Utah 84065, United States of America'
FormattedAddressLines []string `json:"formatted_address_lines"` // '123 Main Street', 'Riverton, Utah 84065', 'United States of America' FormattedAddressLines []string `json:"formatted_address_lines"` // '123 Main Street', 'Riverton, Utah 84065', 'United States of America'
GID string `json:"gid"` // 'openaddresses:address:us/ca/tulare-addresses-county:fe9dfab3d45c4550' GID string `json:"gid"` // 'openaddresses:address:us/ca/tulare-addresses-county:fe9dfab3d45c4550'