response.go

  1package graphhopper
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"io"
  7	"log/slog"
  8	"net/http"
  9	"time"
 10)
 11
 12type Path struct {
 13	Distance         float64         `json:"distance"`          //meters
 14	Time             int64           `json:"time"`              // milliseconds
 15	Ascend           float64         `json:"ascend"`            // meters
 16	Descend          float64         `json:"descend"`           // meters
 17	Points           json.RawMessage `json:"points"`            // either json or polyline6
 18	SnappedWaypoints json.RawMessage `json:"snapped_waypoints"` // start/end snap points json or polyline6
 19	PointsEncoded    bool            `json:"points_encoded"`    // whether polyline6=true
 20	Bbox             []float64       `json:"bbox"`
 21	Details          json.RawMessage `json:"details"` // het list array<[int, int, string]> = index_from, index_to, value
 22}
 23
 24type Response struct {
 25	Hints            json.RawMessage `json:"hints"` // stats (not needed)
 26	Info             json.RawMessage `json:"info"`  // copyright, attribution
 27	Paths            []Path          `json:"paths"`
 28	Instructions     json.RawMessage `json:"instructions"`
 29	decodedPoints    [][]float64
 30	decodedElevation []float64
 31}
 32
 33func Route(pts []PointLngLat, opts ...Option) (*Response, error) {
 34	req, err := NewRequest(pts, opts...)
 35	if err != nil {
 36		return nil, fmt.Errorf("routing error: req: %w", err)
 37	}
 38
 39	slog.Debug("graphhopper.Route: sending request",
 40		"url", req.URL.String(),
 41		"method", req.Method,
 42		"numPoints", len(pts))
 43
 44	client := http.Client{Timeout: 30 * time.Second}
 45	resp, err := client.Do(req)
 46	if err != nil {
 47		return nil, fmt.Errorf("routing error: send: %w", err)
 48	}
 49	defer resp.Body.Close()
 50
 51	slog.Debug("graphhopper.Route: received response",
 52		"statusCode", resp.StatusCode,
 53		"status", resp.Status)
 54
 55	// Check for HTTP error status
 56	if resp.StatusCode != http.StatusOK {
 57		body, _ := io.ReadAll(resp.Body)
 58		slog.Error("graphhopper.Route: API returned error status",
 59			"statusCode", resp.StatusCode,
 60			"status", resp.Status,
 61			"body", string(body))
 62		return nil, ParseAPIError(resp.StatusCode, string(body))
 63	}
 64
 65	var out = new(Response)
 66	if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
 67		slog.Error("graphhopper.Route: failed to decode response",
 68			"error", err)
 69		return nil, fmt.Errorf("routing error: decode: %w", err)
 70	}
 71
 72	slog.Debug("graphhopper.Route: response decoded",
 73		"numPaths", len(out.Paths))
 74
 75	return out, nil
 76}
 77
 78// geoJSONLineString represents a GeoJSON LineString geometry
 79type geoJSONLineString struct {
 80	Type        string      `json:"type"`
 81	Coordinates [][]float64 `json:"coordinates"`
 82}
 83
 84// memoize the result of decoding the encoded paths and stitching them together into a final array of [lon,lat] points and array [elevation]
 85func (r *Response) decode() error {
 86	slog.Debug("graphhopper.Response.decode: starting",
 87		"numPaths", len(r.Paths),
 88		"alreadyDecoded", len(r.decodedPoints) > 0)
 89
 90	// memoized decode
 91	if len(r.decodedPoints) > 0 {
 92		slog.Debug("graphhopper.Response.decode: already decoded, returning cached")
 93		return nil
 94	} else {
 95		r.decodedPoints = make([][]float64, 0)
 96		r.decodedElevation = make([]float64, 0)
 97	}
 98
 99	for pathIdx, p := range r.Paths {
100		slog.Debug("graphhopper.Response.decode: processing path",
101			"pathIndex", pathIdx,
102			"pointsEncoded", p.PointsEncoded,
103			"rawPointsLen", len(p.Points))
104
105		if p.PointsEncoded {
106			slog.Error("graphhopper.Response.decode: polyline6 encoding not supported")
107			panic("TODO: polyline6 decoding not implemented")
108		} else {
109			// API returns GeoJSON LineString when points_encoded=false
110			var geoJSON geoJSONLineString
111			if err := json.Unmarshal(p.Points, &geoJSON); err != nil {
112				slog.Error("graphhopper.Response.decode: error unmarshaling GeoJSON",
113					"pathIndex", pathIdx,
114					"error", err,
115					"rawData", string(p.Points[:min(200, len(p.Points))]))
116				return fmt.Errorf("routing response: error decoding points: %w", err)
117			}
118
119			pts := geoJSON.Coordinates
120
121			slog.Debug("graphhopper.Response.decode: unmarshaled GeoJSON",
122				"pathIndex", pathIdx,
123				"geoJSONType", geoJSON.Type,
124				"numPoints", len(pts))
125
126			if len(pts) == 0 {
127				slog.Warn("graphhopper.Response.decode: no points in path",
128					"pathIndex", pathIdx)
129				continue
130			}
131
132			if len(pts[0]) < 2 {
133				slog.Warn("graphhopper.Response.decode: unexpected point dimensions",
134					"pathIndex", pathIdx,
135					"numPoints", len(pts),
136					"firstPointLen", len(pts[0]))
137				continue
138			}
139
140			slog.Debug("graphhopper.Response.decode: first point sample",
141				"pathIndex", pathIdx,
142				"firstPoint", pts[0])
143
144			// Extract [lon, lat] from each point
145			for ptIdx, pt := range pts {
146				if len(pt) >= 2 {
147					r.decodedPoints = append(r.decodedPoints, []float64{pt[0], pt[1]})
148				} else {
149					slog.Warn("graphhopper.Response.decode: skipping point with insufficient coordinates",
150						"pathIndex", pathIdx,
151						"pointIndex", ptIdx,
152						"pointLen", len(pt))
153				}
154				if len(pt) == 3 {
155					r.decodedElevation = append(r.decodedElevation, pt[2])
156				}
157			}
158
159			slog.Debug("graphhopper.Response.decode: extracted from path",
160				"pathIndex", pathIdx,
161				"extractedPoints", len(r.decodedPoints),
162				"extractedElevation", len(r.decodedElevation))
163		}
164	}
165
166	slog.Debug("graphhopper.Response.decode: completed",
167		"totalPoints", len(r.decodedPoints),
168		"totalElevation", len(r.decodedElevation))
169
170	return nil
171}
172
173func min(a, b int) int {
174	if a < b {
175		return a
176	}
177	return b
178}
179
180// Points returns an array of [lon, lat] points from the encoded response, combining all paths into a single path
181func (r *Response) Points() ([][]float64, error) {
182	if err := r.decode(); err != nil {
183		return nil, err
184	}
185	return r.decodedPoints, nil
186}
187
188// Elevation returns an array of elevation in meters along the full path
189func (r *Response) Elevation() ([]float64, error) {
190	if err := r.decode(); err != nil {
191		return nil, err
192	}
193	return r.decodedElevation, nil
194}
195
196type Stats struct {
197	Distance float64
198	Time     float64
199	Ascend   float64
200	Descend  float64
201}
202
203// Stats returns time, distance, elevation change for the full route path
204func (r *Response) Stats() Stats {
205	var d, t, up, down float64 = 0.0, 0.0, 0.0, 0.0
206	for _, p := range r.Paths {
207		d += p.Distance
208		t += float64(p.Time) / 1000.0 // in fractional seconds from original millisecond precision
209		up += p.Ascend
210		down += p.Descend
211	}
212	return Stats{
213		Distance: d,
214		Time:     t,
215		Ascend:   up,
216		Descend:  down,
217	}
218}
219
220// GeoJSONFeature represents a GeoJSON Feature object with LineString geometry
221type GeoJSONFeature struct {
222	Type       string                 `json:"type"`
223	Geometry   GeoJSONLineString      `json:"geometry"`
224	Properties map[string]interface{} `json:"properties"`
225}
226
227// GeoJSONLineString represents a GeoJSON LineString geometry
228type GeoJSONLineString struct {
229	Type        string      `json:"type"`
230	Coordinates [][]float64 `json:"coordinates"`
231}
232
233// GeoJSONOption is a functional option for configuring GeoJSON output
234type GeoJSONOption func(*GeoJSONFeature)
235
236// WithProperty adds a custom property to the GeoJSON Feature
237func WithProperty(key string, value any) GeoJSONOption {
238	return func(f *GeoJSONFeature) {
239		f.Properties[key] = value
240	}
241}
242
243// ToGeoJSON decodes a Graphhopper response into a Feature with LineString geometry.
244// Coordinates are in Maplibre format [lon, lat].
245// Use functional options to add custom properties.
246func (r *Response) ToGeoJSON(opts ...GeoJSONOption) (*GeoJSONFeature, error) {
247	if err := r.decode(); err != nil {
248		return nil, err
249	}
250
251	feature := &GeoJSONFeature{
252		Type: "Feature",
253		Geometry: GeoJSONLineString{
254			Type:        "LineString",
255			Coordinates: r.decodedPoints,
256		},
257		Properties: make(map[string]any),
258	}
259
260	// Apply functional options
261	for _, opt := range opts {
262		opt(feature)
263	}
264
265	return feature, nil
266}