import { useSettingsStore } from '../../store/settingsStore' import { decodePolyline } from './transitGeometry' import { haversineKm } from '../../utils/geo' import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types' /** * Valhalla, the second routing engine, asked only the questions OSRM cannot answer. * * The shipped OSRM hosts are built without excludable road classes: their car profile * says so in its own Lua ("no memory for classes on demand server"), and `exclude=` * comes back as HTTP 400 on every leg. So the one thing a road tripper keeps asking * for — drive this without the tolls — has never worked on a default install. * * FOSSGIS runs a public Valhalla on the whole planet next to it, keyless, and there * avoidance is a runtime weighting rather than a profile that has to be rebuilt. This * module is the adapter: same question, different engine, answer shaped like the OSRM * one so the callers do not have to care which engine spoke. * * Deliberately NOT a replacement for the OSRM path. Three measured reasons: * - it caps at ten locations, and following a track sends a day's stops plus up to * nine anchors, so the bundled requests would start failing; * - it refuses a point it cannot bind to a road, where OSRM still snaps one. The * Stephansdom, in a pedestrian zone, answers `No path could be found for input` * until a search radius is allowed; * - it prices roads differently, and not by a constant anybody could correct for. * Over twelve European legs the per-leg drive time ran from 14.7 % under OSRM's to * 13.2 % over, and the sign follows the region rather than the road: on Spanish * autovía Valhalla is the faster of the two on every leg measured, through a city * it is half again slower. Those times feed the arrival clocks of a road trip day, * so mixing engines inside one chain would move times nobody asked to move, and * subtracting one engine's figure from the other's, which is what an offer beside * a route invites, measures the gap between two speed models and nothing else. * * Which leaves exactly the shape this module serves: two points, one avoidance, asked * on demand, drawn as an offer next to the route rather than replacing it. */ /** The public instance, matching how the OSRM defaults are shipped rather than configured. */ const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de' /** * How far Valhalla may look for a road under a point, in metres. * * Zero is its default and it is the wrong default for TREK's data, which is full of * museums, viewpoints and restaurants that sit inside a pedestrian zone. Measured on * Vienna's Stephansdom: without a radius the whole route answers error 442, with 50 it * routes, and 200 changed nothing further. So 50 buys the fix and nothing beyond it. */ const SNAP_RADIUS_M = 50 /** TREK's profile names against Valhalla's costing models. */ const COSTING: Record<'driving' | 'walking' | 'cycling', string> = { driving: 'auto', walking: 'pedestrian', cycling: 'bicycle', } /** What a caller tunes in the costing model: a weighting, a switch, or a bicycle type by name. */ export type CostingOptions = Record /** * What a caller can ask to be left out, against the costing option that asks for it. * * Every one of these is a weighting from 0 to 1, not a ban — which is the whole reason * this engine is worth having. Asked to avoid tolls between New York and Boston it * still returns a tolled road, because there is no untolled way, where OSRM's `exclude` * would refuse to answer at all. The caller has to read the answer to find out what it * got, which is what `hasToll` and friends are for. */ const AVOID_OPTION: Record = { motorway: 'use_highways', toll: 'use_tolls', ferry: 'use_ferry', } /** The same three classes the rest of the app names; aliased so this file reads on its own. */ export type AvoidClass = RouteAvoidClass /** * Locations one request may carry. The eleventh answers * `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400. */ const MAX_LOCATIONS = 10 /** * Pause between the pieces of a split day, in milliseconds. * * The public instance allows about one request a second per client, and a day long * enough to be split is exactly the case that would fire two back to back. Matches the * 1100 ms the road trip rail already leaves between its own days. */ const CHUNK_PAUSE_MS = 1100 /** * Extra attempts for one piece of a split day. * * One, not a ladder: the common failure here is the public instance's rate limit, which * a single pause clears, and anything a second attempt cannot fix is not going to be * fixed by a fourth while somebody waits for their map. */ const CHUNK_RETRIES = 1 /** One way of driving a leg, in the units OSRM answers in so callers need no branch. */ export interface ValhallaLeg { coordinates: [number, number][] /** Metres. Valhalla is asked in kilometres and converted here. */ distance: number /** Seconds. */ duration: number /** What the route ACTUALLY has, as opposed to what was asked for. */ hasToll: boolean hasHighway: boolean hasFerry: boolean } /** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */ export interface ValhallaRun { legs: ValhallaLeg[] total: ValhallaLeg /** * Where each waypoint actually met the road. * * Reconstructed from the shape rather than read from a field, because Valhalla echoes * the coordinates it was GIVEN in `trip.locations` and never says where it bound them. * The ends of a leg's line are those points: measured against Füssen, its first vertex * came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same * request to the digit. Without this the dashed kerb-to-door spur from #2273 would * quietly disappear from every day routed by this engine. */ snapped: SnappedWaypoint[] } /** * The instance's Valhalla, or null when it should not ask one. * * Blank falls back to the public instance, the same reading `routing_base_url` gets — * with one condition. An operator who filled in their OWN router chose deliberately * against the public hosts, usually because their instance is not supposed to talk to * third parties at all, and quietly adding a second public host they never asked for * would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD * pipeline answers `exclude` on its own, which is exactly the fallback below. */ export function valhallaBase(): string | null { const settings = useSettingsStore.getState().settings const configured = settings.valhalla_base_url?.trim() if (configured) return configured.replace(/\/+$/, '') return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA } /** True when this engine can be asked at all, so callers can skip building a request. */ export function valhallaAvailable(): boolean { return valhallaBase() !== null } /** Hosts that have already proved they are not a Valhalla, by base URL. */ const notValhalla = new Set() /** * One leg with a road class weighted away, or null when it cannot be had. * * Null for every failure, deliberately: this is an offer next to a route that already * exists, so a host that is down, a rate limit, a point in a field and a genuinely * connected-only-by-motorway leg all mean the same thing to the caller — there is no * second way to show. The one failure worth remembering is a host that is not a * Valhalla at all, because that is true of every leg from now on rather than this one. */ export async function valhallaRouteAvoiding( from: Waypoint, to: Waypoint, profile: 'driving' | 'walking' | 'cycling', avoid: AvoidClass, signal?: AbortSignal, ): Promise { const run = await valhallaRun([from, to], profile, [avoid], signal) return run ? run.total : null } /** * A whole chain of waypoints, one leg per pair, or null when it cannot be had. * * Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled * into one request precisely so it appears at once rather than trickling in — so a day * over the cap is cut into runs that overlap by one point and the legs are concatenated. * That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5] * came back within two metres and under a second of the same day asked whole, because * the shared point is routed to and from identically. * * The pieces go out one after another with a pause, since a day long enough to need * splitting is exactly the one that would otherwise fire two requests inside a second * at a host that allows one. */ export async function valhallaRun( waypoints: Waypoint[], profile: 'driving' | 'walking' | 'cycling', avoid: readonly AvoidClass[], signal?: AbortSignal, costingOptions: Readonly = {}, ): Promise { const base = valhallaBase() if (!base || notValhalla.has(base) || waypoints.length < 2) return null const legs: ValhallaLeg[] = [] const chunks = chunkWaypoints(waypoints) for (const [index, chunk] of chunks.entries()) { if (signal?.aborted) return null if (index > 0) await pause(CHUNK_PAUSE_MS, signal) // Retried per piece rather than per day. The caller retries the whole task, so a // rate limit on the last piece of a split day would otherwise throw away the pieces // that already answered and ask for all of them again — three times over, against a // host that allows one request a second. const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal, costingOptions), signal) // A day is one answer or none. Half a day of legs would be worse than no answer: // the schedule chains leg times blindly and cannot tell that it is missing some, so // it would print a complete, plausible, wrong timetable rather than a gap. if (!run) return null legs.push(...run.legs) } if (legs.length !== waypoints.length - 1) return null return { legs, total: joinLegs(legs), snapped: snappedFrom(legs, waypoints) } } /** * Where each waypoint met the road, taken off the ends of the legs that touch it. * * A leg's line starts at the bound position of its first stop and ends at the bound * position of its second, so stop i is the head of leg i, and the last stop is the tail * of the last leg. The distance is measured here rather than reported, the same recovery * `readSnapped` already applies when OSRM leaves the field out. */ function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoint[] { return waypoints.map((waypoint, i) => { const line = i < legs.length ? legs[i].coordinates : legs[legs.length - 1].coordinates const at = i < legs.length ? line[0] : line[line.length - 1] const asked: [number, number] = [waypoint.lat, waypoint.lng] return { asked, at, meters: haversineKm(waypoint, { lat: at[0], lng: at[1] }) * 1000, } }) } /** Runs of at most ten points, each starting on the one the previous ended at. */ function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] { if (waypoints.length <= MAX_LOCATIONS) return [waypoints] const chunks: Waypoint[][] = [] for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) { chunks.push(waypoints.slice(start, start + MAX_LOCATIONS)) } return chunks } function pause(ms: number, signal?: AbortSignal): Promise { return new Promise(resolve => { const timer = setTimeout(resolve, ms) signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true }) }) } /** When this tab last put a question to the public instance. */ let lastPublicAskAt = 0 /** * Waits for this tab's turn at the public instance, which allows about one request a * second per client. Every request to it goes through here, the route and the Tours * elevation alike, so a tour's route and the height question right behind it, or two * features asking at once, no longer reach it inside the same second and come back 429. * The pauses the callers keep themselves still stand; this only adds the wait they * cannot see, the one between unrelated callers. * * Another host is not paced: an operator's own Valhalla sets its own limits. */ export async function valhallaTurn(base: string, signal?: AbortSignal): Promise { if (base !== FOSSGIS_VALHALLA) return for (let wait = lastPublicAskAt + CHUNK_PAUSE_MS - Date.now(); wait > 0; wait = lastPublicAskAt + CHUNK_PAUSE_MS - Date.now()) { await pause(wait, signal) if (signal?.aborted) return } // Taken in the same synchronous step as the check, so two callers woken together // cannot both see a free turn. lastPublicAskAt = Date.now() } /** Forget the last turn, for tests that start from a quiet host. */ export function resetValhallaTurns(): void { lastPublicAskAt = 0 } /** * Waits out the host's one request a second before the next question is put. * * For a caller that asks this engine several things in a row on its own, such as the * offers for one leg: the same pause the pieces of a split day keep, so a picker cannot * fire at a pace the day routing was careful never to. */ export function valhallaSpacing(signal?: AbortSignal): Promise { return pause(CHUNK_PAUSE_MS, signal) } /** * One question, and one more try after a pause when it earned no answer. * * Not when the host just proved it is not a Valhalla at all: that answer will not change * on a second ask, and asking anyway is the request the memo exists to save. */ async function askWithRetry(base: string, ask: () => Promise, signal?: AbortSignal): Promise { let answer = await ask() for (let attempt = 0; !answer && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) { if (notValhalla.has(base)) break await pause(CHUNK_PAUSE_MS, signal) answer = await ask() } return answer } /** The request body for a chain of waypoints, weighted away from `avoid`. */ function routeBody( waypoints: Waypoint[], profile: 'driving' | 'walking' | 'cycling', avoid: readonly AvoidClass[], costingOptions: Readonly = {}, ): Record { const costing = COSTING[profile] // All of them in one request: the options are independent weightings, so asking to // leave out tolls and ferries together is one question rather than two routes to // reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted // away together answered 656.9 km against 339.2 km for the plain route. const options: CostingOptions = { ...costingOptions } for (const cls of avoid) options[AVOID_OPTION[cls]] = 0 return { locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })), costing, costing_options: { [costing]: options }, directions_options: { units: 'kilometers' }, } } /** The parsed answer to one POST, or null for every failure. */ async function postRoute(base: string, body: Record, signal?: AbortSignal): Promise { try { await valhallaTurn(base, signal) if (signal?.aborted) return null const response = await fetch(`${base}/route`, { method: 'POST', // A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a // client to identify itself with. It costs a preflight, which is answered. headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' }, body: JSON.stringify(body), signal, }) if (!response.ok) { // 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake, // or a proxy that swallowed the path. Every other status is about this request: // 400 carries the real refusals (unroutable point, over 1500 km apart) and 429 // is the public instance's one-per-second. if (response.status === 404 || response.status === 405) notValhalla.add(base) return null } return await response.json() } catch { return null } } /** The most points a description of a line asks about; a longer line is thinned evenly, its ends kept. */ const TRACE_MAX_POINTS = 4000 /** How long a description may take before it counts as failed, so a router that hangs does not leave its card waiting. */ const TRACE_TIMEOUT_MS = 30_000 /** * How a line is matched to the ways under it: as a mountain bike rides, the * bicycle costing that refuses the fewest ways, so a road bike's route and a * gravel one are described alike; or on foot with every grade of path open, * since a hike's line (MAP-03) may run on a T6 ridge the matcher must not * refuse, and steps and mountain paths a run takes (RUN-02) stay on the line * (matched as a bike, a trail run over steps was described as the street * beside them). Matching is no routing: the limit a hike was planned with * plays no part. A hike and a run are matched alike; they differ in whether * the router's own hiking grade is asked for. */ const TRACE_COSTING = { bike: { costing: 'bicycle', costing_options: { bicycle: { bicycle_type: 'Mountain' } } }, foot: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } }, run: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } }, } as const export type TraceMatching = keyof typeof TRACE_COSTING const TRACE_ATTRIBUTES = ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', 'edge.way_id', 'edge.begin_shape_index', 'edge.end_shape_index'] /** The router's hiking grade is asked along a bike tour's and a run's line, never a hike's (MAP-03 reads OpenStreetMap). */ function traceAttributesFor(matching: TraceMatching): string[] { return matching === 'foot' ? TRACE_ATTRIBUTES : [...TRACE_ATTRIBUTES, 'edge.sac_scale'] } /** * BIK-01, RUN-02 and MAP-03: how the router describes the ways under a line * (`trace_attributes`): the surface, the use and road class, the cycle lane, * the OpenStreetMap way of every edge and, but for a hike, the router's * hiking grade (`sac_scale`, 0 where the way has none), with the line as * matched. A run's grade shares (RUN-02) read the router's grade; the grade a * hike shows (MAP-03) comes from OpenStreetMap itself, since the router's * reading may be normalised or derived, so it is not asked for there. Null for * every failure: the description is an addition beside a route that already * exists. */ export async function valhallaTraceAttributes( line: readonly [number, number][], signal?: AbortSignal, matching: TraceMatching = 'bike' ): Promise { const base = valhallaBase() if (!base || line.length < 2) return null const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS)) const shape = line.filter((_, index) => index % step === 0 || index === line.length - 1).map(([lat, lon]) => ({ lat, lon })) const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(TRACE_TIMEOUT_MS)]) : AbortSignal.timeout(TRACE_TIMEOUT_MS) try { await valhallaTurn(base, signal) if (signal?.aborted) return null const response = await fetch(`${base}/trace_attributes`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' }, body: JSON.stringify({ shape, ...TRACE_COSTING[matching], shape_match: 'map_snap', filters: { attributes: traceAttributesFor(matching), action: 'include', }, }), signal: bounded, }) if (!response.ok) return null return await response.json() } catch { return null } } /** One request, at most ten locations. */ async function requestRun( base: string, waypoints: Waypoint[], profile: 'driving' | 'walking' | 'cycling', avoid: readonly AvoidClass[], signal?: AbortSignal, costingOptions: Readonly = {}, ): Promise | null> { const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal) return data === null ? null : runFrom(data) } /** * The ways of driving one leg this engine would offer, its preferred one first, or null. * * The offers for a trip that avoids something have to come from the engine that routes * that trip's days, or the list and the rail describe two different drives: OSRM's * quickest road beside a day Valhalla had routed was offered as the faster way, and * taking it changed nothing. Valhalla answers `alternates` between exactly two locations, * which is all one leg is, and weighs every one of them the way it weighed the day. * `costingOptions` carries the rest of how that day was weighed, such as a hike's limit * and preferences (RTE-02), so a section of a tour is offered ways under its own rules. */ export async function valhallaAlternates( from: Waypoint, to: Waypoint, profile: 'driving' | 'walking' | 'cycling', avoid: readonly AvoidClass[], count: number, signal?: AbortSignal, costingOptions: Readonly = {}, ): Promise { const base = valhallaBase() if (!base || notValhalla.has(base)) return null const body = { ...routeBody([from, to], profile, avoid, costingOptions), ...(count > 0 ? { alternates: count } : {}) } return askWithRetry(base, async () => { const data = await postRoute(base, body, signal) return data === null ? null : alternatesFrom(data) }, signal) } /** * Every way in an answer that asked for alternates: `trip` first, then each * `alternates[].trip`, read the way `runFrom` reads a single leg. * * Null when the preferred one cannot be read, because the others are offered against it. * An alternate that cannot be read is dropped on its own: one bad entry is no reason to * lose the leg's other ways. */ export function alternatesFrom(data: unknown): ValhallaLeg[] | null { const primary = runFrom(data) if (!primary) return null const extra = (data as { alternates?: unknown })?.alternates const others = Array.isArray(extra) ? extra.map(alt => runFrom(alt)?.total ?? null) : [] return [primary.total, ...others.filter((leg): leg is ValhallaLeg => leg !== null)] } /** * Several legs read as one, the way a caller that only wants the whole drive sees it. * * The seam matters more than it looks. Every leg ends where the next begins, so a plain * concatenation repeats that vertex — and this line is not only drawn: a via dropped on * the route is projected onto it to work out which leg it belongs after, and that index * is written to the database. A repeated point can put the via on the wrong leg, which * is a stored wrong answer rather than an invisible one. */ function joinLegs(legs: ValhallaLeg[]): ValhallaLeg { const coordinates: [number, number][] = [] for (const leg of legs) { const last = coordinates[coordinates.length - 1] const first = leg.coordinates[0] const continues = last && first && last[0] === first[0] && last[1] === first[1] coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates)) } return { coordinates, distance: legs.reduce((sum, l) => sum + l.distance, 0), duration: legs.reduce((sum, l) => sum + l.duration, 0), // One tolled leg makes the drive a tolled drive: `some`, never `every`, or a day // with a single toll booth in it would be labelled toll-free. hasToll: legs.some(l => l.hasToll), hasHighway: legs.some(l => l.hasHighway), hasFerry: legs.some(l => l.hasFerry), } } /** * The Valhalla answer as a leg, or null when it is not one. * * Split out because it is the whole of the format knowledge and the only part worth * testing without a network: the native shape, not `format=osrm`. That flag exists and * would have kept the response side unchanged, but it drops `has_toll` — the one field * this is all for — and carries every intersection instead, which measured 5.7 times * larger on Paris to Lyon. Translating a smaller answer beats parsing a familiar one. */ /** * The answer read leg by leg, which is what a whole day needs. * * Valhalla gives every leg its own summary, so a chain comes back already divided the * way a road trip day wants it: this stop to the next, with its own distance, its own * drive time, and its own honest `has_toll`. Reading only the trip summary would flatten * a day into one number and lose the per-leg times the schedule is built from. * * The fallback to the trip summary is for a single leg only. Where several legs carry no * summary of their own there is no honest way to divide one total between them, and a * guess here would arrive as a wrong arrival time rather than as a missing one. */ export function runFrom(data: unknown): Omit | null { const trip = tripOf(data) if (!trip || !Array.isArray(trip.legs) || !trip.legs.length) return null const legs: ValhallaLeg[] = [] for (const leg of trip.legs) { if (typeof leg.shape !== 'string') return null const summary = leg.summary ?? (trip.legs.length === 1 ? trip.summary : undefined) if (!summary) return null // Precision 6, verified against a known point: decoded as 5 the first vertex of a // Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193. const measure = measured(summary, decodePolyline(leg.shape, 6)) if (!measure) return null legs.push(measure) } return { legs, total: joinLegs(legs) } } interface ValhallaSummary { [key: string]: number | boolean | undefined } function tripOf(data: unknown) { return (data as { trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary } })?.trip } /** A summary and a shape as a leg, or null when the summary carries no numbers. */ function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null { if (!coordinates.length) return null const length = Number(summary.length) const time = Number(summary.time) if (!Number.isFinite(length) || !Number.isFinite(time)) return null return { coordinates, // Asked in kilometres, answered in kilometres, handed on in metres because that is // what every caller already reads off an OSRM route. distance: length * 1000, duration: time, hasToll: summary.has_toll === true, hasHighway: summary.has_highway === true, hasFerry: summary.has_ferry === true, } } /** * Whether a route actually avoids what it was asked to avoid. * * The reason this function exists rather than a boolean on the request: `use_tolls: 0` * is a weighting, so between two points with no untolled connection Valhalla answers a * tolled route and reports it honestly. Labelling that "No tolls" would be a lie on * screen, and the flags are in the answer precisely so nobody has to guess. */ export function legAvoids(leg: ValhallaLeg, avoid: AvoidClass): boolean { if (avoid === 'toll') return !leg.hasToll if (avoid === 'motorway') return !leg.hasHighway return !leg.hasFerry }