import { useSettingsStore } from '../../store/settingsStore' import { pluginsApi } from '../../api/client' import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types' import { haversineKm } from '../../utils/geo' import { formatDistance } from '../../utils/units' import { countRoute } from './routeUsageCounter' import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute' import type { RouteUsageSurface } from '@trek/shared' // FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the // project-osrm.org demo is car-only (it ignores the profile in the URL). Use // the matching profile so walking routes follow footpaths, not the road network. const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = { driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving', walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot', cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike', } /** OSRM's own profile names, which a self-hosted instance serves under its own paths. */ const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = { driving: 'driving', walking: 'foot', cycling: 'bike', } /** * Where to ask for a route. * * The public FOSSGIS hosts allow roughly one request a second, which a road trip — every * leg of every day — runs into immediately. An instance that runs its own OSRM sets * `routing_base_url` and everything routes against that instead; the server has to name * the same origin in its connect-src, or the browser blocks the requests silently. * * A configured base is expected to serve the standard OSRM layout, * `/route/v1//…`, which is what `osrm-routed` does out of the box. */ /** * A configured base URL without its trailing slashes. * * Walked rather than matched with `/\/+$/`: that pattern backtracks over a run of * slashes, and the value comes from an instance setting somebody types. Linear either * way in practice, but the regex is the shape a scanner is right to flag. */ function withoutTrailingSlashes(value: string): string { let end = value.length while (end > 0 && value[end - 1] === '/') end -= 1 return value.slice(0, end) } function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string { const configured = useSettingsStore.getState().settings.routing_base_url?.trim() if (!configured) return OSRM_PROFILE_BASE[profile] return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}` } /** * Permission to turn around at a stop, for requests that have stops to turn around at. * * OSRM's car profile forbids a u-turn at an INTERMEDIATE waypoint unless asked otherwise, * and a refusal is not local: the whole request comes back `400 NoRoute`, every leg of it, * however many were fine. A stop on a dead end is enough. Gouffre de Padirac snaps onto * `Route du Puits au Salvage`, which goes nowhere else, so a day that visits it in the * middle lost all its legs at once while each of its pairs routed perfectly on its own. * That is also why the fault looked like it depended on how the stops were spread across * days: a pair on a day boundary is asked for on its own, and a request with nothing in * the middle has nothing to refuse. * * Correct as well as convenient here. TREK's waypoints are places somebody stops at, not * shape hints, and the way out of a dead end IS the way back in, and the constraint was * buying a detour even where it did not refuse outright. * * Empty for two waypoints, which have no middle, so the many two-point callers * (alternatives, the day connectors, booking geometry) keep the URL they had. */ const U_TURN_PARAM = '&continue_straight=false' function uTurnParam(waypoints: readonly Waypoint[]): string { return waypoints.length > 2 ? U_TURN_PARAM : '' } /** * `fetch`, with the request counted. * * Every route TREK draws goes out from here, and nowhere else, which makes this the one * place that can answer how much routing an instance really does — the number behind * "could we host an engine ourselves". Counted: how many requests, of what kind, how * many waypoints, roughly how far, and whether the host refused. Not counted, because it * is never sent: where any of it was. * * Distance is the straight line along the waypoint chain rather than the routed length, * which is only in the answer and differs per response shape. It is a floor on the real * figure, and a floor is enough to tell a 1500 km per-request limit from a 150 km one. * * An aborted request is not a failure: the map cancels constantly while someone drags. */ async function routedFetch( url: string, signal: AbortSignal | undefined, kind: RouteUsageSurface, profile: 'driving' | 'walking' | 'cycling', waypoints: readonly Waypoint[], ): Promise { const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim() let km = 0 for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i]) const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km } const asked = withoutUTurnIfRefused(url) try { const response = await fetch(asked, { signal }) countRoute({ ...sample, failed: !response.ok }) if (response.ok || !asked.includes(U_TURN_PARAM)) return response const plain = await droppedUTurn(asked, response) if (!plain) return response const second = await fetch(plain, { signal }) countRoute({ ...sample, failed: !second.ok }) return second } catch (err) { if (!(err instanceof DOMException && err.name === 'AbortError')) { countRoute({ ...sample, failed: true }) } throw err } } /** * Hosts that answered `InvalidQuery` to `continue_straight`, by origin. * * OSRM refuses an unknown query parameter outright rather than ignoring it, so a router * that has never heard of this one would refuse EVERY request with a stop in the middle, * which is a far worse fault than the one it fixes. `osrm-routed` has understood it * since 5.6, so this is about something else answering under `routing_base_url`, and the * first real request is the probe: there is no separate one. * * By origin rather than per profile, because a router either knows the parameter or does * not; and in a Set rather than a setting, so pointing the instance at a router that does * know it starts asking again without a reload. Same shape and the same reasoning as * `excludeUnsupported` below. */ const uTurnUnsupported = new Set() const originOf = (url: string): string => { try { return new URL(url).origin } catch { return url } } /** The url as it should go out, with the permission dropped for a host that refused it. */ function withoutUTurnIfRefused(url: string): string { if (!url.includes(U_TURN_PARAM)) return url return uTurnUnsupported.has(originOf(url)) ? url.replace(U_TURN_PARAM, '') : url } /** * The same url without the u-turn permission, once it is clear the host objects to the * PARAMETER and not to the route. Null when the refusal was about the route itself. * * OSRM says which it is: `InvalidQuery` names the query string, `NoRoute` names the road. * Reading the body is safe here because this response is about to be thrown away either * way, and it only happens on a 400 for a request that carried the parameter. */ async function droppedUTurn(url: string, response: Response): Promise { if (response.status !== 400) return null let code: unknown try { code = (await response.clone().json())?.code } catch { return null } if (code !== 'InvalidQuery') return null uTurnUnsupported.add(originOf(url)) return url.replace(U_TURN_PARAM, '') } /** * A routing request the host refused, as opposed to one it answered with "no route". * * The public OSRM instances allow roughly one request a second and answer 429 above that. * Both used to arrive as the same generic Error, so a caller could not tell "back off and * try again" from "these two points are not connected by road" — and a road trip, which * asks for every leg of every day, hits the first case constantly while the second is * rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one. */ export class RoutingRefusedError extends Error { constructor(readonly status: number, readonly retryAfterMs: number | null) { super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated') this.name = 'RoutingRefusedError' } /** True when waiting and asking again is the right response. */ get isRateLimit(): boolean { return this.status === 429 || this.status === 503 } } /** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */ function retryAfterMs(response: Response): number | null { const raw = response.headers.get('Retry-After') if (!raw) return null const seconds = Number(raw) if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000) const at = Date.parse(raw) return Number.isNaN(at) ? null : Math.max(0, at - Date.now()) } // Cache route responses keyed by the exact waypoint list. Routes are stable, so // this avoids re-hitting the public OSRM demo server on every day switch / reorder. const routeCache = new Map() const ROUTE_CACHE_MAX = 200 /** * A route profile is either one of the built-in OSRM profiles or a plugin profile * key `plugin:/` — the route toggle offers those for every * active routeProvider plugin, and calculateRouteWithLegs dispatches on the prefix. */ export type RouteProfileKey = 'driving' | 'walking' | 'cycling' | (string & {}) export function parsePluginProfile(profile: string): { pluginId: string; profileId: string } | null { if (!profile.startsWith('plugin:')) return null const rest = profile.slice('plugin:'.length) const slash = rest.indexOf('/') if (slash <= 0 || slash === rest.length - 1) return null return { pluginId: rest.slice(0, slash), profileId: rest.slice(slash + 1) } } /** * Which engine priced a route: OSRM, the second engine that weighs road classes away, or * a route provider plugin. Their figures are three different speed models, so a figure * from one read against a figure from another measures the models, not the roads. */ export type RouteEngine = 'osrm' | 'valhalla' | 'plugin' /** * The classes a request really weighs away, in a stable order, and none when it cannot. * * Only the driving profile has classes to leave out and only Valhalla can leave them out, * so every other request is asked plainly whatever the trip says. One function for the * day routing and for the offers beside it, so the two cannot disagree about which engine * a leg belongs to. */ export function avoidedClasses(profile: RouteProfileKey, avoid: readonly AvoidClass[]): AvoidClass[] { return profile === 'driving' && avoid.length > 0 && valhallaAvailable() ? [...avoid].sort((a, b) => a.localeCompare(b)) : [] } /** The engine `calculateRouteWithLegs` hands a request with this profile and avoidance to. */ export function routeEngineFor(profile: RouteProfileKey, avoid: readonly AvoidClass[]): RouteEngine { if (parsePluginProfile(profile)) return 'plugin' return avoidedClasses(profile, avoid).length ? 'valhalla' : 'osrm' } /** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */ export async function calculateRoute( waypoints: Waypoint[], profile: 'driving' | 'walking' | 'cycling' = 'driving', { signal }: { signal?: AbortSignal } = {} ): Promise { if (!waypoints || waypoints.length < 2) { throw new Error('At least 2 waypoints required') } const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false${uTurnParam(waypoints)}` const response = await routedFetch(url, signal, 'route', profile, waypoints) if (!response.ok) { throw new RoutingRefusedError(response.status, retryAfterMs(response)) } const data = await response.json() if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { throw new Error('No route found') } const route = data.routes[0] const coordinates: [number, number][] = route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng]) const distance: number = route.distance let duration: number if (profile === 'walking') { duration = distance / (5000 / 3600) } else if (profile === 'cycling') { duration = distance / (15000 / 3600) } else { duration = route.duration } const walkingDuration = distance / (5000 / 3600) const drivingDuration: number = route.duration return { coordinates, distance, duration, distanceText: formatRouteDistance(distance), durationText: formatDuration(duration), walkingText: formatDuration(walkingDuration), drivingText: formatDuration(drivingDuration), } } /** * Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the * day's activity runs, so the drawn route starts and ends at the day's accommodation * (matching the sidebar's hotel connectors). A bookend is only added when both its * hotel and the first/last located waypoint exist; passing nulls leaves `runs` * untouched. The shared first/last waypoint is repeated so the polylines join. */ export function withHotelBookends( runs: T[][], firstWay: T | undefined, lastWay: T | undefined, startHotel: T | null, endHotel: T | null, ): T[][] { const out: T[][] = [] if (startHotel && firstWay) out.push([startHotel, firstWay]) out.push(...runs) if (endHotel && lastWay) out.push([lastWay, endHotel]) return out } export function generateGoogleMapsUrl(places: Waypoint[]): string | null { const valid = places.filter((p) => p.lat && p.lng) if (valid.length === 0) return null if (valid.length === 1) { return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}` } const stops = valid.map((p) => `${p.lat},${p.lng}`).join('/') return `https://www.google.com/maps/dir/${stops}` } /** A stop that can carry its name into a deep link that has somewhere to put one. */ export type NamedWaypoint = Waypoint & { name?: string | null } /** TREK's route profiles in CoMaps' vocabulary; a plugin profile has no equivalent and drives. */ function coMapsRouteType(profile: RouteProfileKey): string { if (profile === 'walking') return 'pedestrian' if (profile === 'cycling') return 'bicycle' return 'vehicle' } /** * Open a day's stops in CoMaps for offline navigation (#1904). * * CoMaps has two links and they trade against each other. `route` builds real * turn-by-turn in the given travel mode but takes a start and a destination and * nothing between them; `map` takes any number of named pins but routes nothing. * So a two-stop day goes as a route — everything it has fits, mode included — * and a longer one goes as pins, because handing over the whole day and letting * CoMaps route leg by leg beats quietly dropping the middle of someone's plan. * A day that needs the full itinerary as one navigable track has the GPX export. * * https rather than `cm://` for the same reason as `getCoMapsUrlForPlace`. */ export function generateCoMapsUrl(places: NamedWaypoint[], profile: RouteProfileKey = 'driving'): string | null { const valid = places.filter((p) => p.lat != null && p.lng != null) if (valid.length === 0) return null const label = (p: NamedWaypoint) => encodeURIComponent(p.name?.trim() || `${p.lat},${p.lng}`) if (valid.length === 2) { const [from, to] = valid return `https://comaps.at/route?sll=${from.lat},${from.lng}&saddr=${label(from)}` + `&dll=${to.lat},${to.lng}&daddr=${label(to)}&type=${coMapsRouteType(profile)}` } const pins = valid.map((p) => `ll=${p.lat},${p.lng}&n=${label(p)}`).join('&') return `https://comaps.at/map?v=1&${pins}` } // Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine. function sqDist(a: Waypoint, b: Waypoint): number { return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2 } // Length of visiting `order` in sequence, optionally pinned to a fixed start and/or end anchor. // With start === end this is a closed loop back to the anchor (a day out from and back to the hotel). function tourLength(order: Waypoint[], start?: Waypoint, end?: Waypoint): number { if (order.length === 0) return 0 let total = 0 if (start) total += Math.sqrt(sqDist(start, order[0])) for (let i = 0; i < order.length - 1; i++) total += Math.sqrt(sqDist(order[i], order[i + 1])) if (end) total += Math.sqrt(sqDist(order[order.length - 1], end)) return total } // Greedy nearest-neighbor ordering, seeded at the start anchor when there is one. function nearestNeighborOrder(valid: T[], start?: Waypoint): T[] { const visited = new Set() const result: T[] = [] let current: Waypoint if (start) { current = start } else { current = valid[0] visited.add(0) result.push(valid[0]) } while (result.length < valid.length) { let nearestIdx = -1 let minDist = Infinity for (let i = 0; i < valid.length; i++) { if (visited.has(i)) continue const d = sqDist(valid[i], current) if (d < minDist) { minDist = d; nearestIdx = i } } if (nearestIdx === -1) break visited.add(nearestIdx) current = valid[nearestIdx] result.push(valid[nearestIdx]) } return result } // 2-opt: repeatedly reverse a sub-segment whenever it shortens the tour. This removes the crossings // a pure nearest-neighbor pass leaves behind. The start/end anchors stay fixed, so a round trip // (start === end) is untangled into a clean loop rather than an open path. function twoOptImprove(order: T[], start?: Waypoint, end?: Waypoint): T[] { if (order.length < 3) return order let best = order let bestLen = tourLength(best, start, end) let improved = true while (improved) { improved = false for (let i = 0; i < best.length - 1; i++) { for (let j = i + 1; j < best.length; j++) { const candidate = best.slice(0, i).concat(best.slice(i, j + 1).reverse(), best.slice(j + 1)) const len = tourLength(candidate, start, end) if (len < bestLen - 1e-12) { best = candidate bestLen = len improved = true } } } } return best } /** * Reorders waypoints to minimize travel distance: a nearest-neighbor pass for a good starting order, * then 2-opt to untangle crossings. Optional anchors (e.g. the day's accommodation) pin the route's * ends — start === end makes it a loop out from and back to the hotel; a transfer day runs start → end. */ export function optimizeRoute(places: T[], anchors: RouteAnchors = {}): T[] { const { start, end } = anchors const valid = places.filter((p) => p.lat && p.lng) if (valid.length <= 1) return places // Two unanchored stops have no meaningful order to optimize; anchors can still flip them. if (valid.length === 2 && !start && !end) return places const order = twoOptImprove(nearestNeighborOrder(valid, start), start, end) // A round trip's loop direction is arbitrary, so orient it to begin at the stop nearest the hotel — // that reads naturally as "leave the hotel, head to the closest place, …, come back". if (start && end && start.lat === end.lat && start.lng === end.lng && order.length > 1) { if (sqDist(order[order.length - 1], start) < sqDist(order[0], start)) order.reverse() } return order } /** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */ export async function calculateSegments( waypoints: Waypoint[], { signal }: { signal?: AbortSignal } = {} ): Promise { if (!waypoints || waypoints.length < 2) return [] const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration${uTurnParam(waypoints)}` const response = await routedFetch(url, signal, 'segments', 'driving', waypoints) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found') const legs = data.routes[0].legs return legs.map((leg: { distance: number; duration: number }, i: number): RouteSegment => { const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] const walkingDuration = leg.distance / (5000 / 3600) return { mid, from, to, distance: leg.distance, duration: leg.duration, walkingText: formatDuration(walkingDuration), drivingText: formatDuration(leg.duration), distanceText: formatRouteDistance(leg.distance), } }) } /** * One OSRM call per waypoint-run that returns BOTH the real road geometry (for the * map) and per-leg distance/duration (for the sidebar connectors). Results are cached * by the exact waypoint list. Throws on OSRM failure so callers can fall back to a * straight line. */ export async function calculateRouteWithLegs( waypoints: Waypoint[], { signal, profile = 'driving', tripId, dayId, avoid = [] }: { signal?: AbortSignal profile?: RouteProfileKey tripId?: number | string | null dayId?: number | null /** Road classes to weight away. Only the driving profile has any, and only Valhalla can. */ avoid?: readonly AvoidClass[] } = {} ): Promise { if (!waypoints || waypoints.length < 2) { return { coordinates: [], distance: 0, duration: 0, legs: [] } } const avoiding = avoidedClasses(profile, avoid) const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') // The cached result carries formatted leg distances, so the active distance unit is // part of the key — otherwise switching km↔mi would return stale text (#1300). // A plugin route is trip-/day-specific (it may return different charging stops for // the same coordinates on a different day), so its key includes tripId/dayId; // the built-in OSRM profiles are context-free and leave those out. // // The avoidance belongs in the key for two reasons pointing opposite ways. Without it // turning the switch on returns the tolled route out of cache in under a millisecond, // so nothing on screen changes and it reads as a broken toggle. And the leak the other // way is worse: this Map is shared with the ordinary day planner, the studio and the // booking geometry, none of which ask for avoidance, so an avoid-routed answer stored // under the plain key would hand them a road nobody drives and a second engine's // times. So the avoiding key only ever holds a Valhalla answer: when Valhalla cannot // answer and OSRM drives the request after all, that answer is filed under the plain // key, where it is exactly what an ordinary caller would have got. Filed under the // avoiding key it stood in for the avoided road until the next reload, however soon // Valhalla came back. const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : '' const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : '' const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}` const cacheKey = `${plainKey}${avoidScope}` const cached = routeCache.get(cacheKey) if (cached) return cached if (avoiding.length) { const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal) if (routed) { cacheRoute(cacheKey, routed) return routed } // Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather // than blank, and the answer says that nothing was avoided, with the reason, so the // rail flags the day instead of implying the setting held on a road nobody weighed. const plain = routeCache.get(plainKey) ?? await osrmWithLegs(waypoints, coords, profile, signal) cacheRoute(plainKey, plain) return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } } } // Plugin profile (`plugin:/`): the server invokes that routeProvider // and normalizes its answer; null means the provider failed or refused, and the // throw makes callers fall back to straight lines exactly like an OSRM outage. const pluginProfile = parsePluginProfile(profile) if (pluginProfile) { if (tripId == null) throw new Error('Plugin routing needs a trip context') const { route } = await pluginsApi.pluginRoute(pluginProfile.pluginId, pluginProfile.profileId, { tripId, dayId: dayId ?? null, waypoints: waypoints.map((p) => ({ lat: p.lat, lng: p.lng })), }, { signal }) if (!route) throw new Error('No route found') const legs: RouteSegment[] = route.legs.map((leg, i): RouteSegment => { const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] return { mid, from, to, distance: leg.distance, duration: leg.duration, walkingText: formatDuration(leg.distance / (5000 / 3600)), drivingText: formatDuration(leg.duration), distanceText: formatRouteDistance(leg.distance), durationText: formatDuration(leg.duration), ...(leg.note ? { noteText: leg.note } : {}), } }) const result: RouteWithLegs = { coordinates: route.coordinates, distance: route.distance, duration: route.duration, legs, ...(route.viaPoints.length ? { vias: route.viaPoints } : {}), } routeCache.set(cacheKey, result) if (routeCache.size > ROUTE_CACHE_MAX) { const oldest = routeCache.keys().next().value if (oldest !== undefined) routeCache.delete(oldest) } return result } const result = await osrmWithLegs(waypoints, coords, profile, signal) cacheRoute(cacheKey, result) return result } /** The OSRM answer for a chain, one leg per waypoint pair. Throws like every OSRM call here. */ async function osrmWithLegs( waypoints: Waypoint[], coords: string, profile: RouteProfileKey, signal?: AbortSignal, ): Promise { // Written as literals rather than narrowing `profile`: its type is an open string union // (plugins name their own modes), which no comparison narrows to the three OSRM knows. const osrmProfile: 'driving' | 'walking' | 'cycling' = profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving' const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration${uTurnParam(waypoints)}` const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found') const route = data.routes[0] const coordinates: [number, number][] = route.geometry.coordinates.map( ([lng, lat]: [number, number]) => [lat, lng] ) const legs: RouteSegment[] = (route.legs || []).map( (leg: { distance: number; duration: number }, i: number): RouteSegment => { const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] const walkingDuration = leg.distance / (5000 / 3600) return { mid, from, to, distance: leg.distance, duration: leg.duration, walkingText: formatDuration(walkingDuration), drivingText: formatDuration(leg.duration), distanceText: formatRouteDistance(leg.distance), durationText: formatDuration(leg.duration), } } ) const snapped = readSnapped(data, waypoints) return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) } } /** Store a route and drop the oldest once the cache is over its cap. */ function cacheRoute(key: string, route: RouteWithLegs): void { routeCache.set(key, route) if (routeCache.size > ROUTE_CACHE_MAX) { const oldest = routeCache.keys().next().value if (oldest !== undefined) routeCache.delete(oldest) } } /** * A whole chain from the second engine, weighted away from some road classes. * * Shaped exactly like the OSRM answer above so the callers never branch on which engine * spoke — same leg list, same formatted text, same snap positions. What it adds is * `avoidance`: what was asked for against what the road actually turned out to be, which * is the only honest way to label a weighting that can fail. * * Null rather than a throw when it cannot answer, so the caller falls through to OSRM * and the day stays drawn. */ async function routeAvoidingWithLegs( waypoints: Waypoint[], avoid: AvoidClass[], signal?: AbortSignal, ): Promise { const sample = { profile: 'driving' as const, surface: 'legs' as const, selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(), waypoints: waypoints.length, km: waypoints.slice(1).reduce((sum, point, i) => sum + haversineKm(waypoints[i], point), 0), } const run = await valhallaRun(waypoints, 'driving', avoid, signal) // Counted by hand because the adapter speaks to a different engine with a different // verb, and the counter exists to answer "how much routing does this instance do" — // an engine it cannot see is exactly the traffic that would go missing from it. if (!signal?.aborted) countRoute({ ...sample, failed: !run }) if (!run) return null const legs: RouteSegment[] = run.legs.map((leg, i): RouteSegment => { const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] return { mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2], from, to, distance: leg.distance, duration: leg.duration, walkingText: formatDuration(leg.distance / (5000 / 3600)), drivingText: formatDuration(leg.duration), distanceText: formatRouteDistance(leg.distance), durationText: formatDuration(leg.duration), } }) return { coordinates: run.total.coordinates, distance: run.total.distance, duration: run.total.duration, legs, snapped: run.snapped, avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) }, hasFerry: run.total.hasFerry, } } /** * The snap positions out of an OSRM answer, or undefined if it did not describe them. * * OSRM reports this in every response and TREK has thrown it away since the first route * was drawn. `distance` is the straight line from the coordinate we sent to the road it * used; it is recomputed from the two points when the field is missing or nonsense, so a * mirror that trims its answers still produces a usable gap rather than a wrong one. * * All-or-nothing on purpose: a partial list would have to be indexed by waypoint anyway, * and one bad entry would put a spur on the wrong stop. */ function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] | undefined { const raw = (data as { waypoints?: unknown })?.waypoints if (!Array.isArray(raw) || raw.length !== waypoints.length) return undefined const out: SnappedWaypoint[] = [] for (let i = 0; i < raw.length; i++) { const loc = (raw[i] as { location?: unknown })?.location if (!Array.isArray(loc) || loc.length < 2) return undefined const [lng, lat] = loc as [number, number] if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined const asked: [number, number] = [waypoints[i].lat, waypoints[i].lng] const at: [number, number] = [lat, lng] const reported = (raw[i] as { distance?: unknown })?.distance const meters = typeof reported === 'number' && Number.isFinite(reported) && reported >= 0 ? reported : haversineKm({ lat: asked[0], lng: asked[1] }, { lat, lng }) * 1000 out.push({ asked, at, meters }) } return out } function getDistanceUnit(): DistanceUnit { return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric' } function formatRouteDistance(meters: number): string { const unit = getDistanceUnit() if (unit === 'metric' && meters < 1000) { return `${Math.round(meters)} m` } return formatDistance(meters / 1000, unit) } function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) if (h > 0) { return `${h} h ${m} min` } return `${m} min` } /** One way of driving a leg, as a router offers it. */ export interface RouteAlternative { coordinates: [number, number][] distance: number duration: number /** Where this route differs most from the first one — the point that would pin it. */ divergence: { lat: number; lng: number } | null /** Set when this way exists because a road class was left out of it. */ avoids?: AvoidClass /** * Which engine priced this route. Absent means OSRM, which is what answers every route * of a trip that avoids nothing, except the avoidance offer on a default install. * * It exists because the engines do not agree on speed: measured over twelve European * legs the per-leg drive times run from 14.7 % under OSRM's to 13.2 % over, and the * sign depends on the region: Valhalla is faster on Spanish autovía and slower through * a city. So a figure from one of them subtracted from a figure from the other is not a * difference in driving time, it is the gap between two speed models, and the reader * has no way of telling the two apart. */ engine?: RouteEngine /** Whether the way crosses by ferry, where the engine that drew it says so. */ hasFerry?: boolean } /** What two routes are compared by: their line, and their figures where there is none. */ export type RoadLine = Pick /** * Road classes worth asking the router to leave out, in the order they are tried. * * OSRM's own alternative search is conservative: it only offers a second road when the * detour is short enough and shares little enough with the first, so on a long motorway * run it usually answers with exactly one route — while a perfectly good, slower way over * the B-roads exists and is what somebody opening "other ways" is looking for. Excluding * a class asks a different question and reliably produces that road. * * Only for driving: the foot and bike profiles have no excludable classes, and asking * anyway earns an `InvalidOptions` for nothing. * * Ferry is on the list for Valhalla's sake and skipped on the OSRM path below. OSRM * does carry a ferry class, but on a leg where a ferry is the only crossing it answers * NoRoute rather than the long way round, which is not an alternative anybody can use. * Valhalla weights it instead and drives around, which is the whole point. */ const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const /** * How far two lines may stray from each other and still be the same road, in kilometres. * * Parallel carriageways, a different slip road onto the same motorway and the noise * between two engines' idea of where a road is all sit well under this. A way somebody * would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to * Copenhagen, which is a genuinely different road, peaks 2.3 km off the default. */ const SAME_ROAD_KM = 0.5 /** * Two lines are the same road. * * Length and duration used to decide this, and stopped being enough when a second engine * started answering. Valhalla prices roads differently: measured over twelve European * legs its distances ran 1.8 % above OSRM's, so on a 470 km leg the same road comes back * thirteen kilometres apart and a 200 m tolerance calls it a discovery. Two identical * blue lines on the map is exactly what that looks like. Duration is the worse of the * two to lean on: over the same twelve legs it ranged from 14.7 % under OSRM's to 13.2 % * over, with the sign set by the region rather than by the road. * * The geometry does not care which engine drew it, so it decides whenever there is one. * The numbers stay as the fallback for a line with no shape to compare. * * Measured both ways round. Asked only whether `b` stays near `a`, a road that runs the * whole of `b` and then turns off to a point and back passed as `b`, because every point * of `b` is on it; that is exactly the shape of a leg bent by a via, and the picker and * the check behind a choice both have to tell it from the plain road. */ export function sameRoad(a: RoadLine, b: RoadLine): boolean { const there = furthestFrom(b.coordinates, a.coordinates) const back = furthestFrom(a.coordinates, b.coordinates) if (there && back) return Math.max(there.km, back.km) < SAME_ROAD_KM return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60 } /** * Hosts that have already answered "I do not do exclude", by base URL. * * Both public hosts TREK ships with reject the parameter outright with HTTP 400: the * upstream car profile is built without excludable classes, and only a self-hosted OSRM * built through the MLD pipeline supports it. Without this, every long leg on a default * install pays two extra requests to be told twice that it cannot have what it asked for, * against a host that allows one request per second. * * The first real attempt is the probe; there is no separate one. Only a 400 is recorded, * because only a 400 is about the host rather than about this particular question: a 429 * is a rate limit, an abort is the user moving on, and a `NoRoute` means this leg has no * way round the motorway while the next one might. * * Per base URL rather than global, so pointing the instance at an own router that does * support it starts asking again without a reload. */ const excludeUnsupported = new Set() /** * One route with a road class left out, or null when the router will not or cannot. * * Every failure is a null: a rate limit answers 429, and a leg with no way round the * motorway answers `NoRoute`. Neither is worth an error on screen — they just mean this * particular question had no answer. */ async function routeExcluding( from: Waypoint, to: Waypoint, profile: 'driving' | 'walking' | 'cycling', exclude: AvoidClass, signal?: AbortSignal, ): Promise { // Valhalla first, because on a default install it is the only one of the two that // can answer at all. It weights the class away instead of banning it, so it also // answers on a leg where the class cannot be avoided — and says so, which is why the // result is thrown away below unless the road really came back without it. const weighted = await valhallaExcluding(from, to, profile, exclude, signal) if (weighted) return weighted // OSRM has a ferry class too, but on a leg where the ferry IS the crossing it answers // NoRoute rather than driving round, so there is nothing to offer and no point paying // a request to be told so. if (exclude === 'ferry') return null return osrmExcluding(from, to, profile, exclude, signal) } /** * The same leg from Valhalla, weighted away from one class, or null. * * The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so * between two points with no untolled connection the answer is still a tolled road. * Offering that as "No tolls" would put a lie on the map, so it is dropped and the * OSRM attempt gets its turn. */ async function valhallaExcluding( from: Waypoint, to: Waypoint, profile: 'driving' | 'walking' | 'cycling', exclude: AvoidClass, signal?: AbortSignal, ): Promise { if (!valhallaAvailable()) return null const sample = { profile, surface: 'alternatives' as const, selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(), waypoints: 2, km: haversineKm(from, to), } const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal) // Counted here rather than in the adapter, so the adapter stays a format translator // and every routing request an instance makes still lands in the one counter. if (!signal?.aborted) countRoute({ ...sample, failed: !leg }) if (!leg || !legAvoids(leg, exclude)) return null return valhallaOffer(leg) } /** A Valhalla leg as an offer, carrying the engine that priced it and what it crosses. */ function valhallaOffer(leg: ValhallaLeg): RouteAlternative { return { coordinates: leg.coordinates, distance: leg.distance, duration: leg.duration, divergence: null, engine: 'valhalla', hasFerry: leg.hasFerry, } } /** The same leg from OSRM with the class excluded outright, or null. */ async function osrmExcluding( from: Waypoint, to: Waypoint, profile: 'driving' | 'walking' | 'cycling', exclude: AvoidClass, signal?: AbortSignal, ): Promise { const base = routeBaseFor(profile) if (excludeUnsupported.has(base)) return null try { const coords = `${from.lng},${from.lat};${to.lng},${to.lat}` const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson` const response = await routedFetch(url, signal, 'alternatives', profile, [from, to]) if (!response.ok) { // 400 is the router saying the parameter itself is not available here, which is // true of every leg from now on. Anything else is about this request alone. if (response.status === 400) excludeUnsupported.add(base) return null } const data = await response.json() const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null if (!route?.geometry?.coordinates?.length) return null return { coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]), distance: route.distance, duration: route.duration, divergence: null, } } catch { return null } } /** * The ways of driving one leg, not one day. * * Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so * asking for a whole day would mean splitting it into one request per leg — which is the * bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once * instead of trickling in. Asked for a single leg, on demand, it costs one extra request. * * `divergence` is the point on each alternative that lies furthest from the default route. * It is what makes a choice persistable: saving that point as a via forces the router back * onto this road on every future request, without storing a polyline that would go stale * with the next OSM update. * * `avoid` is the classes the trip weighs away. A driving leg of such a trip is routed by * Valhalla, so its offers come from Valhalla too, weighed the same way, and never from * OSRM: a list holding both engines is what offered a road the rail was not on as the * quicker one, and clicking it changed nothing. */ export async function calculateAlternatives( from: Waypoint, to: Waypoint, profile: 'driving' | 'walking' | 'cycling' = 'driving', { signal, limit = 3, avoid = [] }: { signal?: AbortSignal; limit?: number; avoid?: readonly AvoidClass[] } = {}, ): Promise { const avoiding = avoidedClasses(profile, avoid) const routes = avoiding.length ? await alternativesAvoiding(from, to, avoiding, limit, signal) : await alternativesFromOsrm(from, to, profile, limit, signal) if (!routes.length) return routes // The first route is what the router would have given anyway; the others are measured // against it so each one can be pinned by the point that makes it different. const [primary, ...rest] = routes for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates) return routes } /** * The offers for a leg of a trip that weighs classes away, every one of them Valhalla's. * * Throws when Valhalla has nothing to say, so the picker reports a router that is not * answering. Quietly offering OSRM's roads instead is the very mix this path exists to end. * * One way back gets the question the OSRM path asks too: the same drive with one more * class left out, only of those the trip does not already avoid, and only where the road * that comes back really is without it. Paced like the pieces of a split day, because the * public instance allows one request a second. */ async function alternativesAvoiding( from: Waypoint, to: Waypoint, avoiding: AvoidClass[], limit: number, signal?: AbortSignal, ): Promise { const sample = { profile: 'driving' as const, surface: 'alternatives' as const, selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(), waypoints: 2, km: haversineKm(from, to), } const answered = await valhallaAlternates(from, to, 'driving', avoiding, Math.max(0, limit - 1), signal) if (!signal?.aborted) countRoute({ ...sample, failed: !answered }) if (!answered?.length) throw new Error('No route found') const routes: RouteAlternative[] = [] for (const leg of answered) { const offer = valhallaOffer(leg) if (!routes.some(r => sameRoad(r, offer))) routes.push(offer) } if (routes.length >= 2) return routes for (const cls of EXCLUDABLE_CLASSES) { if (avoiding.includes(cls)) continue await valhallaSpacing(signal) if (signal?.aborted) break const run = await valhallaRun([from, to], 'driving', [...avoiding, cls], signal) if (!signal?.aborted) countRoute({ ...sample, failed: !run }) if (!run || !legAvoids(run.total, cls)) continue const detour = valhallaOffer(run.total) if (routes.some(r => sameRoad(r, detour))) continue routes.push({ ...detour, avoids: cls }) break } return routes } /** The offers for a leg of a trip that avoids nothing: OSRM's, and one avoidance offer. */ async function alternativesFromOsrm( from: Waypoint, to: Waypoint, profile: 'driving' | 'walking' | 'cycling', limit: number, signal?: AbortSignal, ): Promise { const coords = `${from.lng},${from.lat};${to.lng},${to.lat}` const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson` const response = await routedFetch(url, signal, 'alternatives', profile, [from, to]) if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response)) const data = await response.json() if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return [] const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({ coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]), distance: r.distance, duration: r.duration, divergence: null, })) // Nothing but the router's own preference came back, which is what it answers on most // long legs. Rather than reporting "only one sensible way", ask a different question: // the same drive without the motorway, then without the tolls, then without a ferry. // Tried one at a time and stopped as soon as one lands, so a leg that does have a // second road costs one extra request rather than three. // // This used to produce nothing at all on a default install: OSRM's `exclude` is HTTP // 400 on both public hosts, whose car profile is built without excludable classes. It // now asks Valhalla first, where avoidance is a runtime weighting, and only falls back // to OSRM's `exclude` for an instance running its own MLD-built router. if (routes.length < 2 && profile === 'driving') { for (const exclude of EXCLUDABLE_CLASSES) { if (signal?.aborted) break const detour = await routeExcluding(from, to, profile, exclude, signal) if (detour && !routes.some(r => sameRoad(r, detour))) { detour.avoids = exclude routes.push(detour) break } } } return routes } /** The point of `line` that lies furthest from `reference`, in plain degrees. */ function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null { const furthest = furthestFrom(line, reference) return furthest ? { lat: furthest.lat, lng: furthest.lng } : null } /** * The same point, plus how far off it actually is. * * The search itself stays in squared degrees with a cosine on the longitude: it only has * to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys * nothing for it. The winner is converted once at the end, because `sameRoad` needs a * real distance rather than a rank, and half a degree is a different number of metres in * Andalusia than in Lapland. * * `index` is where on `line` the point sits, so points taken off one line can be put back * in the order that line drives through them. */ export function furthestFrom( line: [number, number][], reference: [number, number][], ): { index: number; lat: number; lng: number; km: number } | null { if (!line.length || !reference.length) return null // Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a // full cross product of two thousand-point lines is not worth the milliseconds. const step = Math.max(1, Math.floor(reference.length / 200)) let bestIndex = -1 let bestNeighbour: [number, number] | null = null let bestDist = -1 for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) { const near = nearestOn(line[i], reference, step) if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at } } if (bestIndex < 0) return null const [lat, lng] = line[bestIndex] const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0 return { index: bestIndex, lat, lng, km } } /** * Where `reference` passes closest to `point`, as a squared distance in the same scaled * degrees and the spot on the line it was measured to. * * Coarse, then fine. The sampled vertices find the stretch, and the segments either side * of the nearest one find the road itself. Measured to the sampled vertex alone, a point * on the very same road sat up to half a sample apart from it, which on a thousand * kilometre leg is kilometres: two engines' lines of one motorway then read as two roads. */ function nearestOn( point: [number, number], reference: [number, number][], step: number, ): { d: number; at: [number, number] } { const cos = Math.cos((point[0] * Math.PI) / 180) const squared = (p: [number, number]): number => { const dLat = point[0] - p[0] const dLng = (point[1] - p[1]) * cos return dLat * dLat + dLng * dLng } let nearestIndex = 0 let nearest = Infinity for (let j = 0; j < reference.length; j += step) { const d = squared(reference[j]) if (d < nearest) { nearest = d; nearestIndex = j } } let at = reference[nearestIndex] const last = Math.min(reference.length - 1, nearestIndex + step) for (let k = Math.max(0, nearestIndex - step); k < last; k++) { const on = closestOnSegment(point, reference[k], reference[k + 1], cos) const d = squared(on) if (d < nearest) { nearest = d; at = on } } return { d: nearest, at } } /** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */ function closestOnSegment( p: [number, number], a: [number, number], b: [number, number], cos: number, ): [number, number] { const dx = (b[1] - a[1]) * cos const dy = b[0] - a[0] const length = dx * dx + dy * dy if (length === 0) return a const t = Math.max(0, Math.min(1, (((p[1] - a[1]) * cos) * dx + (p[0] - a[0]) * dy) / length)) return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] }