type.go
1package waypoint
2
3import (
4 "fmt"
5 "log/slog"
6 "net/http"
7 "strconv"
8 "strings"
9
10 "git.kilimanjaro.io/rtw/routing/graphhopper"
11)
12
13// Waypoint is the type produced by the waypoint extension in client/src/waypoint.ts
14type Waypoint struct {
15 Type string `json:"type"` // type is always "waypoint"
16 ID string `json:"id"`
17 // contracts:ignore TypeEquivalence - Point uses []float64 for [lon, lat] coordinates
18 Point []float64 `json:"point"` // [lon, lat]
19 Label string `json:"label"`
20 GID string `json:"gid"`
21 Country string `json:"country"`
22 Cons []string `json:"cons,omitempty"`
23 From map[string]Route `json:"from,omitempty"`
24 Nonroutable bool `json:"nonroutable,omitempty"`
25 // Error contains routing error codes as JSON array: ["RouteTooFar", ...]
26 // This field is populated when routing fails for this waypoint
27 Error []string `json:"error,omitempty"`
28}
29
30// Route is the result of calculating a route for a particular set of constraints identified by the key
31// for this route in the map[string]Route (i.e. Route field) of the waypoint.
32// - ID is the content hash of the resulting route
33// - Distance is in kilometers
34// - Time in seconds
35// - Bounding box defined as Maplibre LngLatBoundsLike [float, float, float, float] = [min_lon, min_lat, max_lon, max_lat]
36type Route struct {
37 ID string `json:"id,omitempty"`
38 Distance float64 `json:"d,omitempty"`
39 Time float64 `json:"t,omitempty"`
40 Bbox []float64 `json:"bbox,omitempty"`
41}
42
43// ConstraintType
44type ConstraintType string
45
46const (
47 Adventure ConstraintType = "adv"
48 MaxSpeed ConstraintType = "max" // max speed used to exclude some roads
49 DailyKMs ConstraintType = "dkm" // max daily KMs to ride
50 BreakBeforeDist ConstraintType = "bbd" // add a break before any ride exceeding dist KMs
51 BreakBeforeTime ConstraintType = "bbt" // add a break before any time exceeding time mins
52 DepartAfterTime ConstraintType = "dat" // depart on/after time
53 ArriveBefore ConstraintType = "abt" // arrive on/before time
54 WaypointLocationType ConstraintType = "wlt" // break (default)=b, through=t, via=v, break-through=bt
55 SourceAddedAutomatically ConstraintType = "saa" // added when the source was determined by waypoint order, not user action
56 RemainOverNight ConstraintType = "ron" // added by the user when the ride should end for the day and start again tomorrow
57)
58
59// RouteError represents types of routing errors that can occur
60type RouteError string
61
62const (
63 RouteTooFarError RouteError = "RouteTooFar"
64 PointNotFoundError RouteError = "PointNotFound"
65 RouteFailedError RouteError = "RouteFailed"
66)
67
68// Constraint applied to either routing or time/distance calculations
69type Constraint struct {
70 Type ConstraintType
71 Value string
72}
73
74// ToRouteKey returns a key based on the source point and constraints that affect the routing cost
75// Suffixes are used to distinguish between parameters that can affect the routing between different point
76// For example,
77// - a route key named "abc123" routes from the point with id abc123 using all default routing parameters
78// - a route key named "abc123:adv(80):wlt(v)" routes from the same point, but with Adventure=80 and WaypointLocationType=via
79// - a route key named "abc123:saa" routes from the point with id abc123 and was auto-added by the server
80// - both these routes may coexist and should yield different routing profiles. If not, they will result in the same content hash.
81func ToRouteKey(from Waypoint, to Waypoint, autoAdded bool) (string, error) {
82 if from.ID == "" {
83 return "", fmt.Errorf("route key error: from ID not defined")
84 }
85 out := from.ID
86 for _, c := range to.Cons {
87 constraint, err := ConstraintFromString(c)
88 if err != nil {
89 return "", fmt.Errorf("route key error: %w", err)
90 }
91 switch constraint.Type {
92 // these affect routing cost
93 case Adventure, MaxSpeed, WaypointLocationType:
94 out = out + fmt.Sprintf(":%s(%s)", constraint.Type, constraint.Value)
95 default:
96 continue
97 }
98 }
99 if autoAdded {
100 out = out + ":saa"
101 }
102 return out, nil
103}
104
105// FromRouteKey parses the route key DSL to return the source waypoint ID, slice of constraints
106// Returns an error when the constraint is unparseable
107func FromRouteKey(key string) (fromID string, cons []Constraint, err error) {
108 parts := strings.Split(key, ":")
109 if len(parts) == 1 {
110 // no constraints
111 return key, []Constraint{}, nil
112 }
113 fromID = parts[0]
114 for i := 1; i < len(parts); i++ {
115 c, err := ConstraintFromString(parts[i])
116 if err != nil {
117 return fromID, cons, err
118 }
119 cons = append(cons, c)
120 }
121 return fromID, cons, nil
122}
123
124// valuelessConstraints are constraints that don't require a value
125var valuelessConstraints = map[string]bool{
126 "saa": true, // SourceAddedAutomatically
127 "ron": true, // RemainOverNight
128}
129
130// ConstraintFromString parses the string DSL into a Constraint
131func ConstraintFromString(s string) (Constraint, error) {
132 var key, value string
133 if len(s) > 3 || !valuelessConstraints[s] {
134 // Either len > 3 (requires value format) OR it's not a known valueless constraint
135 s2 := strings.TrimSuffix(s, ")")
136 var found bool
137 key, value, found = strings.Cut(s2, "(")
138 if !found || len(s2) != len(s)-1 {
139 return Constraint{}, fmt.Errorf("error parsing constraint: got %s", s)
140 }
141 } else {
142 // for valueless constraints
143 key = s
144 value = ""
145 }
146 switch key {
147 case "adv", "max", "dkm", "bbd", "bbt", "dat", "abt", "wlt", "saa", "ron":
148 return Constraint{ConstraintType(key), value}, nil
149 default:
150 return Constraint{}, fmt.Errorf("error parsing constraint: unknown type %s", key)
151 }
152}
153
154// ParseConstraints returns the known constraints based on the constraint string DSL,
155// logging a warning when the constraint is unknown rather than error
156func ParseConstraints(c []string) []Constraint {
157 out := make([]Constraint, 0)
158 for _, in := range c {
159 con, err := ConstraintFromString(in)
160 if err != nil {
161 slog.Warn("unknown constraint type", "error", err)
162 continue
163 }
164 out = append(out, con)
165 }
166 return out
167}
168
169// RoutingRequest holds data about a pending request to the Stadia routing API to be parsed into a Route
170// response for the Waypoint identified by the WaypointID and the RouteKey in the From field.
171type RoutingRequest struct {
172 WaypointID string
173 RouteKey string
174 Request *http.Request
175}
176
177// ToRoutingRequest reads each waypoint and returns a data structure of waypoint IDs mapped to routing
178// API HTTP requests that return responses for missing routes
179func ToRoutingRequest(wps ...Waypoint) ([]RoutingRequest, error) {
180 out := make([]RoutingRequest, 0)
181
182 // need at least two waypoints to route, nothing to do
183 if len(wps) < 2 {
184 return out, nil
185 }
186 // create LUT for waypoints by ID
187 lut := make(map[string]Waypoint, len(wps))
188 for _, wp := range wps {
189 lut[wp.ID] = wp
190 }
191 // parse route list for source/destination and constraints, create requests for any missing
192 for _, wp := range wps {
193 for routeKey, route := range wp.From {
194 if route.ID != "" {
195 // route already calculated
196 continue
197 } else {
198 // add a request for this waypoint
199 id, cons, err := FromRouteKey(routeKey)
200 if err != nil {
201 return nil, fmt.Errorf("waypoint %s route %s: %w", wp.ID, routeKey, err)
202 }
203 // create locations
204 srcWp, ok := lut[id]
205 if !ok {
206 return nil, fmt.Errorf("waypoint %s routes from non-existent source: %s", wp.ID, id)
207 }
208 source, err := wpToLocation(srcWp)
209 if err != nil {
210 return nil, err
211 }
212 dest, err := wpToLocation(wp)
213 if err != nil {
214 return nil, err
215 }
216 // construct request options from constraints
217 reqOpts := make([]graphhopper.Option, 0)
218 for _, c := range cons {
219 switch c.Type {
220 case Adventure:
221 // Adventure constraint parsing - value stored for future custom model implementation
222 // Currently all routes use the default "car" profile
223 // Future: implement custom model for adventure routing (tracks, secondary roads, etc.)
224 _, err := strconv.Atoi(c.Value)
225 if err != nil {
226 return nil, fmt.Errorf("waypoint %s route %s: invalid adventure value %q", wp.ID, routeKey, c.Value)
227 }
228 // For now, use default "car" profile - custom model implementation is future work
229 case MaxSpeed:
230 _, err := strconv.Atoi(c.Value)
231 if err != nil {
232 return nil, fmt.Errorf("waypoint %s route %s: invalid maxspeed value %q", wp.ID, routeKey, c.Value)
233 }
234 default:
235 continue
236 }
237 }
238
239 req, err := graphhopper.NewRequest([]graphhopper.PointLngLat{source, dest}, reqOpts...)
240 if err != nil {
241 return nil, fmt.Errorf("error creating routing request: %w", err)
242 }
243 out = append(out, RoutingRequest{wp.ID, routeKey, req})
244 }
245 }
246 }
247 return out, nil
248}
249
250// convert a waypoint to a routing location using the location type constraint if it exists
251func wpToLocation(wp Waypoint) (graphhopper.PointLngLat, error) {
252 if len(wp.Point) < 2 {
253 return graphhopper.PointLngLat{}, fmt.Errorf("waypoint %s has invalid point coordinates: need 2 values, got %d", wp.ID, len(wp.Point))
254 }
255 // Graphhopper doesn't use location types like Valhalla did
256 // We return simple [lon, lat] point
257 return graphhopper.PointLngLat{wp.Point[0], wp.Point[1]}, nil
258}