- DESIGN.md: full design incl. driving-trip requirements (R1-R4), stays model, focus mode, mobile, provenance rules - SURVEY.md: open-source landscape - mock/: interaction mock (Florence itinerary, focus mode, stays, region stops, mobile layout) - router/: Go module (stdlib-only) with Router interface (Valhalla + OSRM backends), stop_cost, optimize_stops, corridor, routectl CLI, bench (5 real NE-corridor tasks, 26 checks passing), integration tests, and setup-osrm.sh for the self-hosted router - osm/: NH+MA+CT+NY PBFs (gitignored) + setup artifacts
184 lines
4.2 KiB
Go
184 lines
4.2 KiB
Go
package route
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"maps/router/internal/geo"
|
|
)
|
|
|
|
// Valhalla is a client for a Valhalla routing API
|
|
// (e.g. the hosted https://valhalla1.openstreetmap.de or a self-hosted
|
|
// valhalla instance). Note: hosted instances strip route geometry, so
|
|
// Route.Geometry will be nil.
|
|
type Valhalla struct {
|
|
BaseURL string
|
|
HTTP *http.Client
|
|
name string
|
|
}
|
|
|
|
func NewValhalla(baseURL string) *Valhalla {
|
|
return &Valhalla{
|
|
BaseURL: baseURL,
|
|
HTTP: &http.Client{Timeout: 60 * time.Second},
|
|
name: "valhalla(" + baseURL + ")",
|
|
}
|
|
}
|
|
|
|
func (v *Valhalla) Name() string { return v.name }
|
|
|
|
func (v *Valhalla) costing(p Profile) string {
|
|
if p == ProfileWalk {
|
|
return "pedestrian"
|
|
}
|
|
return "auto"
|
|
}
|
|
|
|
type vLocation struct {
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
}
|
|
|
|
type vRouteReq struct {
|
|
Locations []vLocation `json:"locations"`
|
|
Costing string `json:"costing"`
|
|
Polyline bool `json:"polyline"`
|
|
}
|
|
|
|
type vLeg struct {
|
|
Summary struct {
|
|
Time float64 `json:"time"`
|
|
Length float64 `json:"length"` // km
|
|
} `json:"summary"`
|
|
}
|
|
|
|
type vTrip struct {
|
|
Summary struct {
|
|
Time float64 `json:"time"`
|
|
Length float64 `json:"length"` // km
|
|
MinLat float64 `json:"min_lat"`
|
|
MaxLat float64 `json:"max_lat"`
|
|
MinLon float64 `json:"min_lon"`
|
|
MaxLon float64 `json:"max_lon"`
|
|
} `json:"summary"`
|
|
Legs []vLeg `json:"legs"`
|
|
}
|
|
|
|
type vResp struct {
|
|
Trip vTrip `json:"trip"`
|
|
Message string `json:"message"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func (v *Valhalla) Route(ctx context.Context, profile Profile, pts []geo.Point) (*Route, error) {
|
|
if len(pts) < 2 {
|
|
return nil, fmt.Errorf("route: need >= 2 points, got %d", len(pts))
|
|
}
|
|
req := vRouteReq{Costing: v.costing(profile), Polyline: true}
|
|
for _, p := range pts {
|
|
req.Locations = append(req.Locations, vLocation{p.Lat, p.Lon})
|
|
}
|
|
body, _ := json.Marshal(req)
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, v.BaseURL+"/route", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
resp, err := v.HTTP.Do(httpReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 20<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var vr vResp
|
|
if err := json.Unmarshal(raw, &vr); err != nil {
|
|
return nil, fmt.Errorf("valhalla: bad response (%s): %q", resp.Status, string(raw[:min(len(raw), 200)]))
|
|
}
|
|
if vr.Message != "" {
|
|
return nil, fmt.Errorf("valhalla: %s", vr.Message)
|
|
}
|
|
if vr.Error != "" {
|
|
return nil, fmt.Errorf("valhalla: %s", vr.Error)
|
|
}
|
|
out := &Route{
|
|
Duration: vr.Trip.Summary.Time,
|
|
Distance: vr.Trip.Summary.Length * 1000,
|
|
BBox: BBox{
|
|
MinLat: vr.Trip.Summary.MinLat, MaxLat: vr.Trip.Summary.MaxLat,
|
|
MinLon: vr.Trip.Summary.MinLon, MaxLon: vr.Trip.Summary.MaxLon,
|
|
},
|
|
}
|
|
for _, l := range vr.Trip.Legs {
|
|
out.Legs = append(out.Legs, Leg{Duration: l.Summary.Time, Distance: l.Summary.Length * 1000})
|
|
}
|
|
// Geometry: decode encoded polyline if the instance returns it.
|
|
if pl, ok := decodePolylineField(raw); ok {
|
|
out.Geometry = pl
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decodePolylineField checks for trip.polyline (encoded polyline v0) —
|
|
// hosted instances omit it; self-hosted valhalla with "polyline":true
|
|
// returns it.
|
|
func decodePolylineField(raw []byte) ([]geo.Point, bool) {
|
|
var probe struct {
|
|
Trip struct {
|
|
Polyline string `json:"polyline"`
|
|
} `json:"trip"`
|
|
}
|
|
if err := json.Unmarshal(raw, &probe); err != nil || probe.Trip.Polyline == "" {
|
|
return nil, false
|
|
}
|
|
return decodePolyline(probe.Trip.Polyline), true
|
|
}
|
|
|
|
// decodePolyline decodes a Google encoded polyline (precision 1e-5 or 1e-6).
|
|
func decodePolyline(s string) []geo.Point {
|
|
var pts []geo.Point
|
|
lat, lon := 0, 0
|
|
i := 0
|
|
for i < len(s) {
|
|
var dlat, dlon int
|
|
shift := 0
|
|
for {
|
|
b := int(s[i]) - 63
|
|
i++
|
|
dlat |= (b & 0x1f) << shift
|
|
if b&0x20 == 0 {
|
|
break
|
|
}
|
|
shift += 5
|
|
}
|
|
shift = 0
|
|
for {
|
|
b := int(s[i]) - 63
|
|
i++
|
|
dlon |= (b & 0x1f) << shift
|
|
if b&0x20 == 0 {
|
|
break
|
|
}
|
|
shift += 5
|
|
}
|
|
lat += dlat&1 ^ dlat>>1
|
|
lon += dlon&1 ^ dlon>>1
|
|
pts = append(pts, geo.Point{Lat: float64(lat) / 1e5, Lon: float64(lon) / 1e5})
|
|
}
|
|
return pts
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|