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}