From 96498c01bf1bd047f64af2dd88d49b11f28b291c Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Mon, 27 Apr 2026 19:38:17 +0000 Subject: [PATCH] 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. --- api/routes.go | 1 + platform/geocode/geocode.go | 112 ++++++++++++++++++++++-------------- platform/geocode/sort.go | 49 ++++++++++++++++ platform/types/location.go | 6 ++ resource/geocode.go | 7 +++ stadia/response_type.go | 1 + 6 files changed, 132 insertions(+), 44 deletions(-) create mode 100644 platform/geocode/sort.go diff --git a/api/routes.go b/api/routes.go index bb9ac89a..cd177baa 100644 --- a/api/routes.go +++ b/api/routes.go @@ -102,6 +102,7 @@ func AddRoutes(r *mux.Router) { geocode := resource.Geocode(router) r.Handle("/geocode/by-gid/{id:.*}", handlerJSON(geocode.ByGID)).Methods("GET") 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") publicreport := resource.Publicreport(router) r.Handle("/publicreport/{id}", handlerBasic(publicreport.ByID)).Methods("GET").Name("publicreport.ByIDGet") diff --git a/platform/geocode/geocode.go b/platform/geocode/geocode.go index 4ca083df..1ddf7011 100644 --- a/platform/geocode/geocode.go +++ b/platform/geocode/geocode.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "sort" "time" "github.com/Gleipnir-Technology/bob" @@ -79,7 +80,7 @@ func GeocodeRaw(ctx context.Context, org *models.Organization, address string) ( if err != nil { 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) { 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 { return nil, fmt.Errorf("insert addresses: %w", err) } - return toGeocodeResult(*resp, 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 + return toGeocodeResult(resp.Features, a.String(), addresses) } // 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 } +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 { if len(features) < 2 { return true diff --git a/platform/geocode/sort.go b/platform/geocode/sort.go new file mode 100644 index 00000000..3c5e394d --- /dev/null +++ b/platform/geocode/sort.go @@ -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 +} diff --git a/platform/types/location.go b/platform/types/location.go index 95d73af1..e14a3ca0 100644 --- a/platform/types/location.go +++ b/platform/types/location.go @@ -2,6 +2,7 @@ package types import ( "fmt" + "math" "github.com/Gleipnir-Technology/nidus-sync/db/models" "github.com/Gleipnir-Technology/nidus-sync/h3utils" @@ -36,3 +37,8 @@ func (l Location) GeometryQuery() (string, error) { func LocationFromFS(pl *models.FieldseekerPointlocation) 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)) +} diff --git a/resource/geocode.go b/resource/geocode.go index f15abfe4..bacad4d0 100644 --- a/resource/geocode.go +++ b/resource/geocode.go @@ -59,6 +59,13 @@ func (res *geocodeR) Reverse(ctx context.Context, r *http.Request, location type } 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) { if query.Query == nil { return nil, nhttp.NewBadRequest("you must include a query") diff --git a/stadia/response_type.go b/stadia/response_type.go index 16f114da..aa64626a 100644 --- a/stadia/response_type.go +++ b/stadia/response_type.go @@ -123,6 +123,7 @@ type GeocodeProperties struct { County string `json:"county"` // "Tulare County" CountyA string `json:"county_a"` // 'TL' 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' 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'