From 2d5dca3fb5a9be7c883b68777e3f22bebc66cdaf Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Sun, 5 Apr 2026 21:57:30 +0000 Subject: [PATCH] Add proxied autocomplete for Stadia This allows me to make the format consistent and to cache the intermediate results, which is useful for speed and testing --- .gitignore | 1 + api/routes.go | 3 + platform/geocode/autocomplete.go | 38 ++++++++++ platform/geocode/geocode.go | 3 + resource/geocode.go | 42 +++++++++++ resource/query_params.go | 11 +-- stadia/cmd/geocode-autocomplete/main.go | 99 +++++++++++++++++++++++++ stadia/geocode_autocomplete.go | 72 ++++++++++++++++++ stadia/response_type.go | 4 +- ts/components/AddressSuggestion.vue | 12 ++- ts/type/stadia.ts | 1 - 11 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 platform/geocode/autocomplete.go create mode 100644 resource/geocode.go create mode 100644 stadia/cmd/geocode-autocomplete/main.go create mode 100644 stadia/geocode_autocomplete.go diff --git a/.gitignore b/.gitignore index 71323113..23fca0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules/ postgrid/cmd/send-pdf/send-pdf result stadia/cmd/bulk-geocode/bulk-geocode +stadia/cmd/geocode-autocomplete/geocode-autocomplete stadia/cmd/reverse-geocode/reverse-geocode stadia/cmd/structured-geocode/structured-geocode static/gen/ diff --git a/api/routes.go b/api/routes.go index 74263142..799161ed 100644 --- a/api/routes.go +++ b/api/routes.go @@ -73,6 +73,9 @@ func AddRoutes(r *mux.Router) { // Unauthenticated endpoints district := resource.District(router) r.Handle("/district", handlerJSONSlice(district.List)).Methods("GET") + geocode := resource.Geocode(router) + r.Handle("/geocode/suggestion", handlerJSONSlice(geocode.SuggestionList)).Methods("GET") + //r.HandleFunc("/district", apiGetDistrict).Methods("GET") r.HandleFunc("/district/{slug}/logo", apiGetDistrictLogo).Methods("GET").Name("district.logo.BySlug") r.HandleFunc("/compliance-request/image/pool/{public_id}", getComplianceRequestImagePool).Methods("GET") diff --git a/platform/geocode/autocomplete.go b/platform/geocode/autocomplete.go new file mode 100644 index 00000000..65799a90 --- /dev/null +++ b/platform/geocode/autocomplete.go @@ -0,0 +1,38 @@ +package geocode + +import ( + "context" + "fmt" + + "github.com/Gleipnir-Technology/nidus-sync/db/models" + "github.com/Gleipnir-Technology/nidus-sync/stadia" + "github.com/rs/zerolog/log" +) + +type AutocompleteResult struct { + Detail string + Locality string +} + +func Autocomplete(ctx context.Context, org *models.Organization, address string) ([]*AutocompleteResult, error) { + req := stadia.RequestGeocodeAutocomplete{ + Text: address, + } + maybeAddServiceArea(&req, org) + resp, err := client.GeocodeAutocomplete(ctx, req) + if err != nil { + return nil, fmt.Errorf("client raw geocode failure on %s: %w", address, err) + } + result := make([]*AutocompleteResult, len(resp.Features)) + for i, r := range resp.Features { + if r.Type != "Feature" { + log.Error().Str("type", r.Type).Msg("should be handled from Stadia") + continue + } + result[i] = &AutocompleteResult{ + Detail: r.Properties.Name, + Locality: r.Properties.Locality, + } + } + return result, nil +} diff --git a/platform/geocode/geocode.go b/platform/geocode/geocode.go index 904b8630..d8558f17 100644 --- a/platform/geocode/geocode.go +++ b/platform/geocode/geocode.go @@ -267,6 +267,9 @@ func allFeaturesIdenticalEnough(features []stadia.GeocodeFeature) bool { return true } func maybeAddServiceArea(req stadia.RequestGeocode, org *models.Organization) { + if org == nil { + return + } if org.ServiceAreaXmax.IsNull() || org.ServiceAreaYmax.IsNull() || org.ServiceAreaXmin.IsNull() || diff --git a/resource/geocode.go b/resource/geocode.go new file mode 100644 index 00000000..c916fda0 --- /dev/null +++ b/resource/geocode.go @@ -0,0 +1,42 @@ +package resource + +import ( + "context" + nhttp "github.com/Gleipnir-Technology/nidus-sync/http" + "github.com/Gleipnir-Technology/nidus-sync/platform/geocode" + "net/http" + //"github.com/rs/zerolog/log" +) + +type geocodeR struct { + router *router +} + +type geocodeSuggestion struct { + Detail string `json:"detail"` + Locality string `json:"locality"` +} + +func Geocode(r *router) *geocodeR { + return &geocodeR{ + router: r, + } +} + +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") + } + completions, err := geocode.Autocomplete(ctx, nil, *query.Query) + if err != nil { + return nil, nhttp.NewError("geocode: %w", err) + } + result := make([]*geocodeSuggestion, len(completions)) + for i, c := range completions { + result[i] = &geocodeSuggestion{ + Detail: c.Detail, + Locality: c.Locality, + } + } + return result, nil +} diff --git a/resource/query_params.go b/resource/query_params.go index d6568fb4..c272822a 100644 --- a/resource/query_params.go +++ b/resource/query_params.go @@ -1,14 +1,15 @@ package resource import ( -//"github.com/gorilla/schema" +// "github.com/gorilla/schema" ) type QueryParams struct { - Limit *int `schema:"limit"` - Query *string `schema:"query"` - Sort *string `schema:"sort"` - Type *string `schema:"type"` + Limit *int `schema:"limit"` + OrganizationID *int `schema:"org"` + Query *string `schema:"query"` + Sort *string `schema:"sort"` + Type *string `schema:"type"` } func (qp QueryParams) SortOrDefault(default_name string, ascending bool) (string, bool) { diff --git a/stadia/cmd/geocode-autocomplete/main.go b/stadia/cmd/geocode-autocomplete/main.go new file mode 100644 index 00000000..d766f446 --- /dev/null +++ b/stadia/cmd/geocode-autocomplete/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + + "github.com/Gleipnir-Technology/nidus-sync/stadia" +) + +func main() { + // Define command-line flags + query := flag.String("query", "", "Street address query to autocomplete") + boundaryRectMaxLat := flag.Float64("boundary-rect-max-lat", 0, "The max lat of the boundary") + boundaryRectMinLat := flag.Float64("boundary-rect-min-lat", 0, "The min lat of the boundary") + boundaryRectMaxLon := flag.Float64("boundary-rect-max-lng", 0, "The max lon of the boundary") + boundaryRectMinLon := flag.Float64("boundary-rect-min-lng", 0, "The min lon of the boundary") + focusLat := flag.Float64("focus-lat", 0, "The latitude of the focus point") + focusLng := flag.Float64("focus-lng", 0, "The longitude of the focus point") + + // Parse the flags + flag.Parse() + + // Validate required arguments + if *query == "" { + log.Println("Error: -query is required") + flag.Usage() + os.Exit(1) + } + + if *focusLat != 0 && *focusLng == 0 { + log.Println("Error: you must specify both focus-lat and focus-lng together, not just focus-lat") + flag.Usage() + os.Exit(1) + } + if *focusLat == 0 && *focusLng != 0 { + log.Println("Error: you must specify both focus-lat and focus-lng together, not just focus-lng") + flag.Usage() + os.Exit(1) + } + if (*boundaryRectMaxLat != 0 || + *boundaryRectMinLat != 0 || + *boundaryRectMaxLon != 0 || + *boundaryRectMinLon != 0) && (*boundaryRectMaxLat == 0 || + *boundaryRectMinLat == 0 || + *boundaryRectMaxLon == 0 || + *boundaryRectMinLon == 0) { + log.Println("If you specify one of boundary-rect you need to specify them all") + os.Exit(1) + } + + key := os.Getenv("STADIA_MAPS_API_KEY") + if key == "" { + log.Println("STADIA_MAPS_API_KEY is empty") + os.Exit(1) + } + + client := stadia.NewStadiaMaps(key) + ctx := context.Background() + req := stadia.RequestGeocodeAutocomplete{ + Text: *query, + } + if *focusLat != 0 && *focusLng != 0 { + req.FocusPointLat = focusLat + req.FocusPointLng = focusLng + } + if *boundaryRectMaxLat != 0 { + req.BoundaryRectMaxLat = boundaryRectMaxLat + req.BoundaryRectMinLat = boundaryRectMinLat + req.BoundaryRectMaxLon = boundaryRectMaxLon + req.BoundaryRectMinLon = boundaryRectMinLon + } + resp, err := client.GeocodeAutocomplete(ctx, req) + if err != nil { + log.Printf("err: %v\n", err) + os.Exit(2) + } + log.Printf("type: %s, features: %d\n", resp.Type, len(resp.Features)) + for i, feature := range resp.Features { + log.Printf("feature %d: type %s\n", i, feature.Type) + if feature.Geometry == nil { + log.Printf("\tno geometry") + } else { + log.Printf("\tgeometry %s\n", feature.Geometry.Type) //, feature.Geometry.Coordinates[0], feature.Geometry.Coordinates[1]) + } + log.Printf("\tproperties %s\n", feature.Properties.Layer) + switch feature.Properties.Layer { + case "address": + log.Printf("\t\t%s", feature.Properties.Name) + if feature.Properties.CoarseLocation != nil { + log.Printf("\t\t%s", *feature.Properties.CoarseLocation) + } + log.Printf("\t\t%s", feature.Properties.Precision) + log.Printf("\t\t%s", feature.Properties.Layer) + log.Printf("\t\t%s", feature.Properties.GID) + } + } +} diff --git a/stadia/geocode_autocomplete.go b/stadia/geocode_autocomplete.go new file mode 100644 index 00000000..a00acf48 --- /dev/null +++ b/stadia/geocode_autocomplete.go @@ -0,0 +1,72 @@ +package stadia + +import ( + "context" + "fmt" + + "github.com/google/go-querystring/query" +) + +type RequestGeocodeAutocomplete struct { + Text string `url:"text" json:"text"` + + // Boundary circle parameters + BoundaryCircleLat *float64 `url:"boundary.circle.lat,omitempty"` + BoundaryCircleLon *float64 `url:"boundary.circle.lon,omitempty"` + BoundaryCircleRadius *float64 `url:"boundary.circle.radius,omitempty"` + + BoundaryCountry *string `url:"boundary.country,omitempty"` //comma-delimited ISO 2 or 3 character code + BoundaryGID *string `url:"boundary.gid,omitempty"` // The GID of a region to limit the search to + + // Boundary parameters + BoundaryRectMaxLat *float64 `url:"boundary.rect.max_lat,omitempty"` + BoundaryRectMinLat *float64 `url:"boundary.rect.min_lat,omitempty"` + BoundaryRectMaxLon *float64 `url:"boundary.rect.max_lon,omitempty"` + BoundaryRectMinLon *float64 `url:"boundary.rect.min_lon,omitempty"` + + // Focus point + FocusPointLat *float64 `url:"focus.point.lat,omitempty" json:",omitempty"` + FocusPointLng *float64 `url:"focus.point.lon,omitempty" json:",omitempty"` + + // Other parameters + Lang *string `url:"lang,omitempty" json:"lang,omitempty"` + Layers []string `url:"layers,omitempty,comma" json:"layers,omitempty"` + Size *int `url:"size,omitempty" json:"size,omitempty"` + Sources []string `url:"sources,omitempty,comma" json:"sources,omitempty"` +} + +func (r *RequestGeocodeAutocomplete) SetBoundaryRect(xmin, ymin, xmax, ymax float64) { + r.BoundaryRectMaxLat = &ymax + r.BoundaryRectMinLat = &ymin + r.BoundaryRectMaxLon = &xmax + r.BoundaryRectMinLon = &xmin +} +func (r *RequestGeocodeAutocomplete) SetFocusPoint(x, y float64) { + r.FocusPointLat = &y + r.FocusPointLng = &x +} +func (s *StadiaMaps) GeocodeAutocomplete(ctx context.Context, req RequestGeocodeAutocomplete) (*GeocodeResponse, error) { + // https://docs.stadiamaps.com/geocoding-search-autocomplete/search/ + var result GeocodeResponse + + query, err := query.Values(req) + if err != nil { + return nil, fmt.Errorf("structured geocode query: %w", err) + } + //var api_error Error + resp, err := s.client.R(). + SetQueryParamsFromValues(query). + SetContext(ctx). + SetResult(&result). + SetPathParam("urlBase", s.urlBase). + SetQueryParam("api_key", s.APIKey). + Get("https://{urlBase}/geocoding/v2/autocomplete") + if err != nil { + return nil, fmt.Errorf("autocomplete get: %w", err) + } + + if !resp.IsSuccess() { + return nil, parseError(resp) + } + return &result, nil +} diff --git a/stadia/response_type.go b/stadia/response_type.go index 76b1f1b5..3c96bca0 100644 --- a/stadia/response_type.go +++ b/stadia/response_type.go @@ -98,7 +98,7 @@ type GeocodeMeta struct { // GeocodeFeature represents a GeoJSON feature in the response type GeocodeFeature struct { Type string `json:"type"` // Should be "Feature" - Geometry GeocodeGeometry `json:"geometry"` + Geometry *GeocodeGeometry `json:"geometry"` Properties GeocodeProperties `json:"properties"` } @@ -113,6 +113,7 @@ type GeocodeProperties struct { Addendum map[string]interface{} `json:"addendum,omitempty"` AddressComponents AddressComponents `json:"address_components,omitempty"` Accuracy string `json:"accuracy"` // 'point' + CoarseLocation *string `json:"coarse_location"` // 'Riverton, UT, USA' Confidence float64 `json:"confidence"` // 1 Context Context `json:"context,omitempty"` // bunch of stuff Country string `json:"country"` // 'United States' @@ -132,6 +133,7 @@ type GeocodeProperties struct { MatchType string `json:"match_type"` // 'exact' Name string `json:"name"` // '1234 Main St' PostalCode string `json:"postalcode"` // '93618' + Precision string `json:"precision"` // 'centroid' Region string `json:"region"` // 'California' RegionA string `json:"region_a"` // 'CA' RegionGID string `json:"region_gid"` // 'whosonfirst:region:85688637' diff --git a/ts/components/AddressSuggestion.vue b/ts/components/AddressSuggestion.vue index 8770f10a..d802be88 100644 --- a/ts/components/AddressSuggestion.vue +++ b/ts/components/AddressSuggestion.vue @@ -138,9 +138,11 @@ function handleInput() { async function fetchAddressSuggestions(text: string) { try { - const url = `https://api.stadiamaps.com/geocoding/v2/autocomplete?text=${encodeURIComponent( - text, - )}&focus.point.lat=35&focus.point.lon=-115`; + const q = encodeURIComponent(text); + //const url = `https://api.stadiamaps.com/geocoding/v2/autocomplete?text=${encodeURIComponent( + //text, + //)}&focus.point.lat=35&focus.point.lon=-115`; + const url = `/api/geocode/suggestion?query=${q}`; const response = await fetch(url); const data = await response.json(); @@ -183,8 +185,10 @@ function formatAddressDisplay(address: Address): string { const street = props.address_components.street ?? ""; const location = props.coarse_location ?? ""; return `${num} ${street}, ${location}`.trim(); - } else { + } else if (props.name != "") { return `${props.name ?? ""}, ${props.coarse_location ?? ""}`.trim(); + } else { + return "???"; } } diff --git a/ts/type/stadia.ts b/ts/type/stadia.ts index 35b6ab05..69b60bb0 100644 --- a/ts/type/stadia.ts +++ b/ts/type/stadia.ts @@ -35,7 +35,6 @@ interface Properties { precision?: string; // "centroid" name: string; } - export interface Geometry { type: string; coordinates: [number, number];