goplaces/route.go
2026-01-02 22:38:59 +01:00

371 lines
9.2 KiB
Go

package goplaces
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"sort"
"strings"
)
const (
defaultRoutesBaseURL = "https://routes.googleapis.com"
routesPath = "/directions/v2:computeRoutes"
routesFieldMask = "routes.polyline.encodedPolyline"
)
const (
defaultRouteLimit = 5
defaultRouteRadiusM = 1000
defaultRouteWaypoints = 5
maxRouteWaypoints = 20
earthRadiusMeters = 6371000.0
routePolylinePrecision = 1e5
)
const (
travelModeDrive = "DRIVE"
travelModeWalk = "WALK"
travelModeBicycle = "BICYCLE"
travelModeTwoWheeler = "TWO_WHEELER"
travelModeTransit = "TRANSIT"
)
var travelModes = map[string]struct{}{
travelModeDrive: {},
travelModeWalk: {},
travelModeBicycle: {},
travelModeTwoWheeler: {},
travelModeTransit: {},
}
// RouteRequest describes a query to search along a route.
type RouteRequest struct {
Query string `json:"query"`
From string `json:"from"`
To string `json:"to"`
Mode string `json:"mode,omitempty"`
RadiusM float64 `json:"radius_m,omitempty"`
MaxWaypoints int `json:"max_waypoints,omitempty"`
Limit int `json:"limit,omitempty"`
Language string `json:"language,omitempty"`
Region string `json:"region,omitempty"`
}
// RouteResponse contains sampled waypoints with search results.
type RouteResponse struct {
Waypoints []RouteWaypoint `json:"waypoints"`
}
// RouteWaypoint ties a sampled route location to search results.
type RouteWaypoint struct {
Location LatLng `json:"location"`
Results []PlaceSummary `json:"results"`
}
// Route searches for places along a route between two locations.
func (c *Client) Route(ctx context.Context, req RouteRequest) (RouteResponse, error) {
req = applyRouteDefaults(req)
if err := validateRouteRequest(req); err != nil {
return RouteResponse{}, err
}
polyline, err := c.computeRoutePolyline(ctx, req)
if err != nil {
return RouteResponse{}, err
}
points, err := decodePolyline(polyline)
if err != nil {
return RouteResponse{}, err
}
waypoints := sampleWaypoints(points, req.MaxWaypoints)
if len(waypoints) == 0 {
return RouteResponse{}, errors.New("goplaces: no route waypoints")
}
results := make([]RouteWaypoint, 0, len(waypoints))
for _, waypoint := range waypoints {
response, err := c.Search(ctx, SearchRequest{
Query: req.Query,
Limit: req.Limit,
Language: req.Language,
Region: req.Region,
LocationBias: &LocationBias{
Lat: waypoint.Lat,
Lng: waypoint.Lng,
RadiusM: req.RadiusM,
},
})
if err != nil {
return RouteResponse{}, err
}
results = append(results, RouteWaypoint{
Location: waypoint,
Results: response.Results,
})
}
return RouteResponse{Waypoints: results}, nil
}
func applyRouteDefaults(req RouteRequest) RouteRequest {
req.Query = strings.TrimSpace(req.Query)
req.From = strings.TrimSpace(req.From)
req.To = strings.TrimSpace(req.To)
req.Mode = strings.ToUpper(strings.TrimSpace(req.Mode))
if req.Mode == "" {
req.Mode = travelModeDrive
}
if req.Limit == 0 {
req.Limit = defaultRouteLimit
}
if req.RadiusM == 0 {
req.RadiusM = defaultRouteRadiusM
}
if req.MaxWaypoints == 0 {
req.MaxWaypoints = defaultRouteWaypoints
}
return req
}
func validateRouteRequest(req RouteRequest) error {
if req.Query == "" {
return ValidationError{Field: "query", Message: "required"}
}
if req.From == "" {
return ValidationError{Field: "from", Message: "required"}
}
if req.To == "" {
return ValidationError{Field: "to", Message: "required"}
}
if req.Limit < 1 || req.Limit > maxSearchLimit {
return ValidationError{Field: "limit", Message: fmt.Sprintf("must be 1-%d", maxSearchLimit)}
}
if req.RadiusM <= 0 {
return ValidationError{Field: "radius_m", Message: "must be > 0"}
}
if req.MaxWaypoints < 1 || req.MaxWaypoints > maxRouteWaypoints {
return ValidationError{Field: "max_waypoints", Message: fmt.Sprintf("must be 1-%d", maxRouteWaypoints)}
}
if _, ok := travelModes[req.Mode]; !ok {
return ValidationError{Field: "mode", Message: "must be DRIVE, WALK, BICYCLE, TWO_WHEELER, or TRANSIT"}
}
return nil
}
func (c *Client) computeRoutePolyline(ctx context.Context, req RouteRequest) (string, error) {
body := map[string]any{
"origin": map[string]any{
"address": req.From,
},
"destination": map[string]any{
"address": req.To,
},
"travelMode": req.Mode,
"polylineQuality": "OVERVIEW",
"polylineEncoding": "ENCODED_POLYLINE",
}
if req.Language != "" {
body["languageCode"] = req.Language
}
if req.Region != "" {
body["regionCode"] = req.Region
}
endpoint := c.routesBaseURL + routesPath
payload, err := c.doRequest(ctx, http.MethodPost, endpoint, body, routesFieldMask)
if err != nil {
return "", err
}
var response routesResponse
if err := json.Unmarshal(payload, &response); err != nil {
return "", fmt.Errorf("goplaces: decode route response: %w", err)
}
if len(response.Routes) == 0 {
return "", errors.New("goplaces: no routes returned")
}
polyline := strings.TrimSpace(response.Routes[0].Polyline.EncodedPolyline)
if polyline == "" {
return "", errors.New("goplaces: empty route polyline")
}
return polyline, nil
}
func decodePolyline(encoded string) ([]LatLng, error) {
if strings.TrimSpace(encoded) == "" {
return nil, errors.New("goplaces: empty polyline")
}
points := make([]LatLng, 0, len(encoded)/4)
var lat, lng int
for i := 0; i < len(encoded); {
var delta int
var shift uint
for {
if i >= len(encoded) {
return nil, errors.New("goplaces: invalid polyline")
}
b := int(encoded[i]) - 63
i++
delta |= (b & 0x1f) << shift
shift += 5
if b < 0x20 {
break
}
}
lat += (delta >> 1) ^ (-(delta & 1))
delta = 0
shift = 0
for {
if i >= len(encoded) {
return nil, errors.New("goplaces: invalid polyline")
}
b := int(encoded[i]) - 63
i++
delta |= (b & 0x1f) << shift
shift += 5
if b < 0x20 {
break
}
}
lng += (delta >> 1) ^ (-(delta & 1))
points = append(points, LatLng{
Lat: float64(lat) / routePolylinePrecision,
Lng: float64(lng) / routePolylinePrecision,
})
}
return points, nil
}
func sampleWaypoints(points []LatLng, maxWaypoints int) []LatLng {
if len(points) == 0 || maxWaypoints <= 0 {
return nil
}
if len(points) == 1 {
return []LatLng{points[0]}
}
if maxWaypoints == 1 {
return []LatLng{pointAtDistance(points, totalDistance(points)/2)}
}
if maxWaypoints >= len(points) {
return uniqueWaypoints(points)
}
cumulative := cumulativeDistances(points)
total := cumulative[len(cumulative)-1]
if total == 0 {
return []LatLng{points[0]}
}
spacing := total / float64(maxWaypoints-1)
sampled := make([]LatLng, 0, maxWaypoints)
for i := 0; i < maxWaypoints; i++ {
target := spacing * float64(i)
point := pointAtCumulative(points, cumulative, target)
if len(sampled) == 0 || !samePoint(sampled[len(sampled)-1], point) {
sampled = append(sampled, point)
}
}
return sampled
}
func cumulativeDistances(points []LatLng) []float64 {
distances := make([]float64, len(points))
for i := 1; i < len(points); i++ {
distances[i] = distances[i-1] + distanceMeters(points[i-1], points[i])
}
return distances
}
func totalDistance(points []LatLng) float64 {
if len(points) < 2 {
return 0
}
var total float64
for i := 1; i < len(points); i++ {
total += distanceMeters(points[i-1], points[i])
}
return total
}
func pointAtDistance(points []LatLng, target float64) LatLng {
if len(points) == 0 {
return LatLng{}
}
cumulative := cumulativeDistances(points)
return pointAtCumulative(points, cumulative, target)
}
func pointAtCumulative(points []LatLng, cumulative []float64, target float64) LatLng {
if target <= 0 {
return points[0]
}
total := cumulative[len(cumulative)-1]
if target >= total {
return points[len(points)-1]
}
index := sort.Search(len(cumulative), func(i int) bool {
return cumulative[i] >= target
})
if index == 0 {
return points[0]
}
prev := points[index-1]
next := points[index]
segment := cumulative[index] - cumulative[index-1]
if segment <= 0 {
return next
}
fraction := (target - cumulative[index-1]) / segment
return LatLng{
Lat: prev.Lat + (next.Lat-prev.Lat)*fraction,
Lng: prev.Lng + (next.Lng-prev.Lng)*fraction,
}
}
func uniqueWaypoints(points []LatLng) []LatLng {
result := make([]LatLng, 0, len(points))
for _, point := range points {
if len(result) == 0 || !samePoint(result[len(result)-1], point) {
result = append(result, point)
}
}
return result
}
func samePoint(a, b LatLng) bool {
const epsilon = 1e-6
return math.Abs(a.Lat-b.Lat) < epsilon && math.Abs(a.Lng-b.Lng) < epsilon
}
func distanceMeters(a, b LatLng) float64 {
lat1 := a.Lat * math.Pi / 180
lat2 := b.Lat * math.Pi / 180
dlat := (b.Lat - a.Lat) * math.Pi / 180
dlng := (b.Lng - a.Lng) * math.Pi / 180
sinDLat := math.Sin(dlat / 2)
sinDLng := math.Sin(dlng / 2)
value := sinDLat*sinDLat + math.Cos(lat1)*math.Cos(lat2)*sinDLng*sinDLng
return 2 * earthRadiusMeters * math.Asin(math.Sqrt(value))
}
type routesResponse struct {
Routes []routeItem `json:"routes"`
}
type routeItem struct {
Polyline routePolyline `json:"polyline"`
}
type routePolyline struct {
EncodedPolyline string `json:"encodedPolyline"`
}