mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
64445a5204
A routed section of a hike between two neighbouring points can now be compared with the other ways the router knows between the same two points, under the same limit and preferences. "Compare ways for the next leg" in a waypoint's menu opens a card over the planner map: the way taken now and up to three others, each drawn on the map in its own colour, with length, climb, descent and walking time by the signposts' rule. Figures without heights stay unknown, and the differences to the current way are shown only where both are known. When the router knows no other way, or does not answer, the card says so. Showing, hovering and choosing change nothing. Only "Take this way" does: the router is first asked whether a few shaping points hold the chosen way (the check the road trip's picker already uses), and only then are they set into that one leg as a single step of the history, with a "Way taken" note and its undo at the foot of the map. Other legs, fixed lines, days and points stay as they were, the totals follow from the replanned route, and the shaping points can be dragged like any other. A section that changed while the router was asked, or a way it does not keep to, is not written, and the card says why. The ways are drawn in 3D too.
608 lines
27 KiB
TypeScript
608 lines
27 KiB
TypeScript
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<string, number | string | boolean>
|
|
|
|
/**
|
|
* 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<AvoidClass, string> = {
|
|
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<string>()
|
|
|
|
/**
|
|
* 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<ValhallaLeg | null> {
|
|
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<CostingOptions> = {},
|
|
): Promise<ValhallaRun | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<T>(base: string, ask: () => Promise<T | null>, signal?: AbortSignal): Promise<T | null> {
|
|
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<CostingOptions> = {},
|
|
): Record<string, unknown> {
|
|
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<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
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<unknown> {
|
|
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<CostingOptions> = {},
|
|
): Promise<Omit<ValhallaRun, 'snapped'> | 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<CostingOptions> = {},
|
|
): Promise<ValhallaLeg[] | null> {
|
|
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<ValhallaRun, 'snapped'> | 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
|
|
}
|