feat(client): route every day through Valhalla where the TREK services or an own Valhalla answer

With the TREK services or an own Valhalla, the day plan, the road trip and
the other ways of a leg are routed by Valhalla on foot, by bike and by car,
twenty locations a request. A day it cannot answer (relay refusal, time out,
no way for a leg) goes to OSRM whole and without a word; it is never handed
to FOSSGIS unless it avoids something only Valhalla can avoid. With FOSSGIS
nothing changes: OSRM routes the days and Valhalla the avoidances.

A Valhalla answer is cached under its own key beside the OSRM one, so
switching engines never serves a day from the other engine. The Valhalla
day routing moves out of RouteCalculator into valhallaDayRoute.ts.
This commit is contained in:
Maurice
2026-10-29 12:11:00 +01:00
committed by Maurice
parent ec8df01f85
commit 8fd025e003
8 changed files with 728 additions and 256 deletions
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { pluginsApi, type PluginRouteResult } from '../../api/client'
@@ -387,6 +387,12 @@ function pluginRouteResult(over: Partial<PluginRouteResult> = {}): PluginRouteRe
}
}
// FOSSGIS: OSRM routes the days and Valhalla only what OSRM cannot. Where Valhalla routes
// every day is valhallaDayRoute.test.ts.
beforeEach(() => {
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_valhalla_provider: 'fossgis' } }))
})
afterEach(() => {
vi.restoreAllMocks()
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, distance_unit: 'metric' } })
@@ -913,7 +919,8 @@ describe('calculateAlternatives', () => {
it('FE-COMP-ROUTECALCULATOR-053: an instance with its own OSRM is never sent to the public Valhalla', async () => {
let valhallaCalls = 0
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org' } }))
// No routing service chosen: the own OSRM alone decides.
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org', routing_valhalla_provider: '' } }))
server.use(
http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
const exclude = new URL(request.url).searchParams.get('exclude')
+82 -215
View File
@@ -1,11 +1,12 @@
import { useSettingsStore } from '../../store/settingsStore'
import { pluginsApi } from '../../api/client'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
import type { RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter'
import { cacheRoute, rememberRoute, routeInMemory, storedRoute } from './routeStore'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
import { formatDuration, formatRouteDistance, getDistanceUnit } from './routeText'
import { valhallaAvailable, valhallaRoutesDays, type AvoidClass } from './valhallaRoute'
import { EXCLUDABLE_CLASSES, valhallaDayAlternatives, valhallaDayRoute, valhallaExcluding } from './valhallaDayRoute'
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
@@ -214,12 +215,18 @@ export function parsePluginProfile(profile: string): { pluginId: string; profile
}
/**
* 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.
* Which engine priced a route: OSRM, Valhalla (which routes the days with the TREK
* services or an own Valhalla, and otherwise 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'
/** One of the three profiles both engines know, or null for a plugin's own. */
function builtInProfile(profile: RouteProfileKey): 'driving' | 'walking' | 'cycling' | null {
return profile === 'driving' || profile === 'walking' || profile === 'cycling' ? profile : null
}
/**
* The classes a request really weighs away, in a stable order, and none when it cannot.
*
@@ -234,10 +241,38 @@ export function avoidedClasses(profile: RouteProfileKey, avoid: readonly AvoidCl
: []
}
/** The engine `calculateRouteWithLegs` hands a request with this profile and avoidance to. */
/**
* The engine `calculateRouteWithLegs` hands a request with this profile and avoidance to:
* Valhalla for every day where it routes the days, and for a day that avoids something
* wherever it can; OSRM for the rest.
*/
export function routeEngineFor(profile: RouteProfileKey, avoid: readonly AvoidClass[]): RouteEngine {
if (parsePluginProfile(profile)) return 'plugin'
return avoidedClasses(profile, avoid).length ? 'valhalla' : 'osrm'
if (avoidedClasses(profile, avoid).length) return 'valhalla'
return builtInProfile(profile) && valhallaRoutesDays() ? 'valhalla' : 'osrm'
}
/** The profile Valhalla routes a request under, or null where `routeEngineFor` gives it to another engine. */
function valhallaProfile(profile: RouteProfileKey, avoid: readonly AvoidClass[]): 'driving' | 'walking' | 'cycling' | null {
return routeEngineFor(profile, avoid) === 'valhalla' ? builtInProfile(profile) : null
}
/**
* Where a Valhalla answer is kept: beside the OSRM key, which keeps the shape it always
* had, with the engine and the classes the day weighs away.
*/
function valhallaKey(plainKey: string, avoiding: readonly AvoidClass[]): string {
return `${plainKey}:valhalla${avoiding.length ? `:avoid=${avoiding.join(',')}` : ''}`
}
/**
* OSRM's answer for a day Valhalla could not route. A day that avoided nothing is then
* routed as it always was, without a word. One that avoided something 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.
*/
function withFallbackNoted(plain: RouteWithLegs, avoiding: AvoidClass[]): RouteWithLegs {
return avoiding.length ? { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } } : plain
}
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
@@ -513,6 +548,7 @@ export async function calculateRouteWithLegs(
return { coordinates: [], distance: 0, duration: 0, legs: [] }
}
const valhallaDay = valhallaProfile(profile, avoid)
const avoiding = avoidedClasses(profile, avoid)
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
@@ -520,41 +556,43 @@ export async function calculateRouteWithLegs(
// 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 built-in profiles are context-free and leave those out.
//
// The engine belongs in the key because the two price roads differently: a day OSRM
// drew handed out as Valhalla's after the admin switched engines would keep the old
// times on every device that held it, and the other way round. An OSRM answer keeps
// the key it always had, so the routes already kept on a device stay good.
//
// 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.
// under the plain key would hand them a road nobody drives. So the Valhalla keys only
// ever hold a Valhalla answer: when Valhalla cannot answer and OSRM drives the request
// after all, that answer is filed under the OSRM key, where it is exactly what an
// ordinary caller would have got. Filed under the Valhalla key it stood in for the
// weighed 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 cacheKey = valhallaDay ? valhallaKey(plainKey, avoiding) : plainKey
// A plugin route stays in memory: its answer depends on the trip and the day,
// and may carry live data such as charging stops. The rest is kept on the device
// too, so a trip that shows its whole route opens drawn (#2794).
const cached = pluginScope ? routeInMemory(cacheKey) : await storedRoute(cacheKey)
if (cached) return cached
if (avoiding.length) {
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
if (valhallaDay) {
const routed = await valhallaDayRoute(waypoints, valhallaDay, 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.
// Null is a Valhalla that could not answer at all, and the whole day goes to OSRM:
// one engine per day, never one leg from each.
const plain = (await storedRoute(plainKey)) ?? await osrmWithLegs(waypoints, coords, profile, signal)
cacheRoute(plainKey, plain)
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
return withFallbackNoted(plain, avoiding)
}
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
@@ -634,63 +672,6 @@ async function osrmWithLegs(
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
}
/**
* 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<RouteWithLegs | null> {
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.
*
@@ -722,27 +703,6 @@ function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] |
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][]
@@ -753,8 +713,9 @@ export interface RouteAlternative {
/** 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.
* Which engine priced this route. Absent means OSRM, which answers every route of a
* trip that avoids nothing where Valhalla does not route the days, except the
* avoidance offer.
*
* 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
@@ -771,26 +732,6 @@ export interface RouteAlternative {
/** What two routes are compared by: their line, and their figures where there is none. */
export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'duration'>
/**
* 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
/**
* Hosts that have already answered "I do not do exclude", by base URL.
*
@@ -838,49 +779,6 @@ async function routeExcluding(
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<RouteAlternative | null> {
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,
@@ -928,10 +826,10 @@ async function osrmExcluding(
* 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.
* `avoid` is the classes the trip weighs away. A leg Valhalla routes, because it routes
* the days or because the trip avoids something, gets its offers 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,
@@ -939,9 +837,8 @@ export async function calculateAlternatives(
profile: 'driving' | 'walking' | 'cycling' = 'driving',
{ signal, limit = 3, avoid = [] }: { signal?: AbortSignal; limit?: number; avoid?: readonly AvoidClass[] } = {},
): Promise<RouteAlternative[]> {
const avoiding = avoidedClasses(profile, avoid)
const routes = avoiding.length
? await alternativesAvoiding(from, to, avoiding, limit, signal)
const routes = routeEngineFor(profile, avoid) === 'valhalla'
? await alternativesFromValhalla(from, to, profile, avoidedClasses(profile, avoid), { limit, signal })
: await alternativesFromOsrm(from, to, profile, limit, signal)
if (!routes.length) return routes
@@ -953,54 +850,24 @@ export async function calculateAlternatives(
}
/**
* The offers for a leg of a trip that weighs classes away, every one of them Valhalla's.
* The offers for a leg of a day Valhalla routes, 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.
* A day that weighs classes away has no other engine to ask: this 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. A day that avoids nothing
* went to OSRM whole when Valhalla did not answer it, so its offers do the same.
*/
async function alternativesAvoiding(
async function alternativesFromValhalla(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
avoiding: AvoidClass[],
limit: number,
signal?: AbortSignal,
{ limit, signal }: { limit: number; signal?: AbortSignal },
): Promise<RouteAlternative[]> {
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
const offered = await valhallaDayAlternatives(from, to, profile, avoiding, { limit, signal })
if (offered) return offered
if (avoiding.length) throw new Error('No route found')
return alternativesFromOsrm(from, to, profile, limit, signal)
}
/** The offers for a leg of a trip that avoids nothing: OSRM's, and one avoidance offer. */
+27
View File
@@ -0,0 +1,27 @@
import { useSettingsStore } from '../../store/settingsStore';
import type { DistanceUnit } from '../../types';
import { formatDistance } from '../../utils/units';
/** The unit a route's texts are written in, which is why it is part of the route cache key. */
export function getDistanceUnit(): DistanceUnit {
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric';
}
/** A routed distance as the connectors show it: metres below a kilometre, else the user's unit. */
export function formatRouteDistance(meters: number): string {
const unit = getDistanceUnit();
if (unit === 'metric' && meters < 1000) {
return `${Math.round(meters)} m`;
}
return formatDistance(meters / 1000, unit);
}
/** A routed time in hours and minutes, the minutes rounded down. */
export 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`;
}
@@ -0,0 +1,272 @@
import { encodePolyline6 } from '@trek/shared';
import { http, HttpResponse } from 'msw';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { server } from '../../../tests/helpers/msw/server';
import { useSettingsStore } from '../../store/settingsStore';
import type { Settings, Waypoint } from '../../types';
import { calculateAlternatives, calculateRouteWithLegs, routeEngineFor } from './RouteCalculator';
import { resetValhallaTurns } from './valhallaRoute';
const RELAY = '/api/routing/valhalla/route';
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route';
const OWN_VALHALLA = 'https://valhalla.example.org';
const OSRM = {
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',
} as const;
const PROFILES = [
['driving', 'auto'],
['walking', 'pedestrian'],
['cycling', 'bicycle'],
] as const;
type Point = { lat: number; lon: number };
type RouteBody = { locations: Point[]; costing: string; costing_options: unknown; alternates?: number };
let seed = 0;
/** Points no other test asked for, so neither route cache answers in their place. */
function freshDay(count: number): Waypoint[] {
seed += 1;
return Array.from({ length: count }, (_, i) => ({ lat: 40 + seed / 100, lng: 5 + i / 100 }));
}
/** Valhalla's trip through some points: one leg per pair, two kilometres and five minutes each. */
function valhallaTrip(points: Point[]) {
const legs = points.slice(1).map((to, i) => ({
shape: encodePolyline6([points[i], to]),
summary: { length: 2, time: 300, has_toll: false, has_highway: false, has_ferry: false },
}));
return { legs, summary: { length: 2 * legs.length, time: 300 * legs.length } };
}
/** OSRM's answer for a request's coordinates: one leg per pair, a kilometre and two minutes each. */
function osrmAnswer(coords: string) {
const points = coords.split(';').map((pair) => pair.split(',').map(Number));
const legs = points.slice(1).map(() => ({ distance: 1000, duration: 120 }));
return {
code: 'Ok',
routes: [{ geometry: { coordinates: points }, distance: 1000 * legs.length, duration: 120 * legs.length, legs }],
waypoints: points.map((location) => ({ location, distance: 0 })),
};
}
interface Traffic {
relay: RouteBody[];
fossgis: RouteBody[];
own: RouteBody[];
osrm: string[];
}
const answered = (body: RouteBody) => HttpResponse.json({ trip: valhallaTrip(body.locations) });
/** Every routing host answering, the relay as `relay` says; what each was asked is recorded. */
function listen(relay: (body: RouteBody) => Response = answered): Traffic {
const traffic: Traffic = { relay: [], fossgis: [], own: [], osrm: [] };
const valhalla =
(asked: RouteBody[], answer: (body: RouteBody) => Response) =>
async ({ request }: { request: Request }) => {
const body = (await request.json()) as RouteBody;
asked.push(body);
return answer(body);
};
server.use(
http.post(RELAY, valhalla(traffic.relay, relay)),
http.post(FOSSGIS_VALHALLA, valhalla(traffic.fossgis, answered)),
http.post(`${OWN_VALHALLA}/route`, valhalla(traffic.own, answered)),
...Object.values(OSRM).map((base) =>
http.get(`${base}/:coords`, ({ request, params }) => {
traffic.osrm.push(request.url);
return HttpResponse.json(osrmAnswer(String(params.coords)));
})
)
);
return traffic;
}
const choose = (patch: Partial<Settings>) =>
useSettingsStore.setState((st) => ({ settings: { ...st.settings, ...patch } }));
afterEach(() => {
choose({ routing_valhalla_provider: '', valhalla_base_url: '', routing_base_url: '' });
resetValhallaTurns();
vi.restoreAllMocks();
});
describe('which engine routes a day', () => {
it.each(PROFILES)('VDAY-ENGINE-001: the TREK services and an own Valhalla route every %s day', (profile) => {
choose({ routing_valhalla_provider: 'trek' });
expect([routeEngineFor(profile, []), routeEngineFor(profile, ['toll'])]).toEqual(['valhalla', 'valhalla']);
choose({ routing_valhalla_provider: '', valhalla_base_url: OWN_VALHALLA });
expect([routeEngineFor(profile, []), routeEngineFor(profile, ['toll'])]).toEqual(['valhalla', 'valhalla']);
choose({ routing_valhalla_provider: 'custom' });
expect(routeEngineFor(profile, [])).toBe('valhalla');
});
it.each(PROFILES)('VDAY-ENGINE-002: with FOSSGIS a %s day goes to Valhalla only to avoid by car', (profile) => {
choose({ routing_valhalla_provider: 'fossgis' });
expect(routeEngineFor(profile, [])).toBe('osrm');
expect(routeEngineFor(profile, ['toll'])).toBe(profile === 'driving' ? 'valhalla' : 'osrm');
});
it('VDAY-ENGINE-003: an own OSRM alone keeps every day, and a plugin its own', () => {
choose({ routing_base_url: 'https://osrm.example.org' });
expect(PROFILES.map(([profile]) => routeEngineFor(profile, ['toll']))).toEqual(['osrm', 'osrm', 'osrm']);
choose({ routing_valhalla_provider: 'trek' });
expect(routeEngineFor('plugin:ev/fast', [])).toBe('plugin');
});
it('VDAY-ENGINE-004: while the relay rests after a failure, the days go back to OSRM', async () => {
listen(() => HttpResponse.json({ error: 'resting' }, { status: 503 }));
await calculateRouteWithLegs(freshDay(2), { profile: 'driving' });
expect(routeEngineFor('driving', [])).toBe('osrm');
resetValhallaTurns();
expect(routeEngineFor('driving', [])).toBe('valhalla');
});
});
describe('a day where Valhalla routes the days', () => {
it.each(PROFILES)('VDAY-001: a %s day goes to the relay as %s, OSRM never asked', async (profile, costing) => {
const traffic = listen();
const route = await calculateRouteWithLegs(freshDay(3), { profile });
expect(traffic.relay.map((body) => [body.costing, body.costing_options, body.locations.length])).toEqual([
[costing, { [costing]: {} }, 3],
]);
expect(traffic.osrm).toEqual([]);
expect(traffic.fossgis).toEqual([]);
expect([route.distance, route.duration, route.legs.length, route.snapped?.length]).toEqual([4000, 600, 2, 3]);
expect(route.legs[0]).toMatchObject({ distanceText: '2 km', durationText: '5 min' });
expect(route).not.toHaveProperty('avoidance');
});
it('VDAY-002: the relay takes twenty locations a request, an own Valhalla too', async () => {
let traffic = listen();
await calculateRouteWithLegs(freshDay(12), { profile: 'driving' });
expect(traffic.relay.map((body) => body.locations.length)).toEqual([12]);
choose({ valhalla_base_url: OWN_VALHALLA });
traffic = listen();
const route = await calculateRouteWithLegs(freshDay(25), { profile: 'cycling' });
const asked = traffic.own.map((body) => body.locations);
expect(asked.map((locations) => locations.length)).toEqual([20, 6]);
expect(asked[1][0]).toEqual(asked[0][19]);
expect(route.legs).toHaveLength(24);
expect(traffic.relay).toEqual([]);
});
it('VDAY-003: where the relay cannot answer, OSRM routes the whole day and FOSSGIS is never asked', async () => {
const refusals: Array<() => Response> = [
() => HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }),
() => HttpResponse.json({ error: 'Forbidden' }, { status: 403 }),
() => HttpResponse.json({ error: 'Too large' }, { status: 413 }),
() => HttpResponse.json({ error: 'Too many routing requests' }, { status: 429 }),
() => HttpResponse.json({ error: 'down' }, { status: 500 }),
() => HttpResponse.json({ error: 'The TREK routing service is resting' }, { status: 503 }),
() => HttpResponse.json({ error: 'Expected number, received string' }, { status: 400 }),
() => HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 }),
() => HttpResponse.error(),
];
for (const refusal of refusals) {
resetValhallaTurns();
const traffic = listen(refusal);
const route = await calculateRouteWithLegs(freshDay(3), { profile: 'walking' });
expect([route.distance, route.legs.length]).toEqual([2000, 2]);
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([1, 0, 1]);
expect(traffic.osrm[0]).toContain(OSRM.walking);
expect(route).not.toHaveProperty('avoidance');
}
});
it('VDAY-004: a relay that does not answer in time hands the day to OSRM as well', async () => {
const timeout = AbortSignal.timeout.bind(AbortSignal);
vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) =>
ms === 12_000 ? AbortSignal.abort(new DOMException('signal timed out', 'TimeoutError')) : timeout(ms)
);
const traffic = listen();
const route = await calculateRouteWithLegs(freshDay(2), { profile: 'driving' });
expect(route.distance).toBe(1000);
expect([traffic.fossgis.length, traffic.osrm.length]).toEqual([0, 1]);
});
it('VDAY-005: an own Valhalla that cannot answer leaves the day to OSRM', async () => {
choose({ valhalla_base_url: OWN_VALHALLA });
const traffic = listen();
server.use(http.post(`${OWN_VALHALLA}/route`, () => HttpResponse.json({ error: 'down' }, { status: 502 })));
const route = await calculateRouteWithLegs(freshDay(3), { profile: 'driving' });
expect(route.distance).toBe(2000);
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([0, 0, 1]);
});
it('VDAY-006: a day that avoids something keeps FOSSGIS behind the relay and its ten locations', async () => {
const traffic = listen(() => HttpResponse.json({ error: 'resting' }, { status: 503 }));
const route = await calculateRouteWithLegs(freshDay(12), { profile: 'driving', avoid: ['toll'] });
expect(traffic.relay).toHaveLength(1);
expect(traffic.fossgis.map((body) => body.locations.length)).toEqual([10, 3]);
expect(traffic.osrm).toEqual([]);
expect(route.avoidance).toEqual({ asked: ['toll'], achieved: ['toll'] });
});
it('VDAY-007: with FOSSGIS chosen, a day that avoids nothing is OSRM’s as before', async () => {
choose({ routing_valhalla_provider: 'fossgis' });
const traffic = listen();
const route = await calculateRouteWithLegs(freshDay(3), { profile: 'cycling' });
expect(route.distance).toBe(2000);
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([0, 0, 1]);
});
});
describe('the route cache keeps the engine apart', () => {
it('VDAY-CACHE-001: a day kept from one engine is not handed out as the other’s', async () => {
const points = freshDay(3);
choose({ routing_valhalla_provider: 'trek' });
const traffic = listen();
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(4000);
choose({ routing_valhalla_provider: 'fossgis' });
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(2000);
choose({ routing_valhalla_provider: 'trek' });
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(4000);
expect([traffic.relay.length, traffic.osrm.length]).toEqual([1, 1]);
});
it('VDAY-CACHE-002: OSRM standing in is not filed as the Valhalla day, so the relay is asked again once it is back', async () => {
const points = freshDay(3);
let up = false;
const traffic = listen((body) => (up ? answered(body) : HttpResponse.json({ error: 'down' }, { status: 503 })));
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(2000);
up = true;
resetValhallaTurns();
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(4000);
expect([traffic.relay.length, traffic.osrm.length]).toEqual([2, 1]);
});
});
describe('other ways for a leg Valhalla routes', () => {
const from = { lat: 47, lng: 9 };
const to = { lat: 47, lng: 9.2 };
it('VDAY-ALT-001: the offers come from the relay, weighed like the day, and OSRM is not asked', async () => {
const traffic = listen((body) => {
const detour = [body.locations[0], { lat: 47.3, lon: 9.1 }, body.locations[1]];
return HttpResponse.json({ trip: valhallaTrip(body.locations), alternates: [{ trip: valhallaTrip(detour) }] });
});
const routes = await calculateAlternatives(from, to, 'driving');
expect(traffic.relay.map((body) => [body.costing, body.alternates])).toEqual([['auto', 2]]);
expect(routes.map((route) => route.engine)).toEqual(['valhalla', 'valhalla']);
expect(routes[1].divergence).not.toBeNull();
expect(traffic.osrm).toEqual([]);
});
it('VDAY-ALT-002: where the relay cannot answer, the offers are OSRM’s like the day', async () => {
const traffic = listen(() => HttpResponse.json({ error: 'resting' }, { status: 503 }));
const routes = await calculateAlternatives(from, to, 'walking');
expect(routes).toHaveLength(1);
expect(routes[0].engine).toBeUndefined();
expect(traffic.fossgis).toEqual([]);
expect(traffic.osrm[0]).toContain('alternatives=3');
});
});
@@ -0,0 +1,183 @@
import { sameRoad } from '@trek/shared';
import { useSettingsStore } from '../../store/settingsStore';
import type { RouteSegment, RouteWithLegs, Waypoint } from '../../types';
import { haversineKm } from '../../utils/geo';
import type { RouteAlternative } from './RouteCalculator';
import { formatDuration, formatRouteDistance } from './routeText';
import { countRoute } from './routeUsageCounter';
import {
legAvoids,
valhallaAvailable,
valhallaDayAlternates,
valhallaDayRun,
valhallaRouteAvoiding,
valhallaSpacing,
type AvoidClass,
type ValhallaLeg,
} from './valhallaRoute';
type Profile = 'driving' | 'walking' | 'cycling';
/**
* 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. 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.
*/
export const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const;
/**
* What the usage counter is told about a question to this engine. Counted by hand
* because it 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.
*/
function usage(profile: Profile, surface: 'legs' | 'alternatives', points: readonly Waypoint[]) {
let km = 0;
for (let i = 1; i < points.length; i++) km += haversineKm(points[i - 1], points[i]);
const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim();
return { profile, surface, selfHosted, waypoints: points.length, km };
}
/**
* A whole day from Valhalla, shaped exactly like the OSRM answer so the callers never
* branch on which engine spoke: same leg list, same formatted text, same snap positions.
*
* A day that weighs classes away adds `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. A day that avoids nothing has no such question and leaves it out.
*
* Null rather than a throw when it cannot answer, so the caller routes the whole day
* with OSRM and the day stays drawn.
*/
export async function valhallaDayRoute(
waypoints: Waypoint[],
profile: Profile,
avoid: AvoidClass[],
signal?: AbortSignal
): Promise<RouteWithLegs | null> {
const sample = usage(profile, 'legs', waypoints);
const run = await valhallaDayRun(waypoints, profile, avoid, signal);
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,
...(avoid.length
? { avoidance: { asked: avoid, achieved: avoid.filter((cls) => legAvoids(run.total, cls)) } }
: {}),
hasFerry: run.total.hasFerry,
};
}
/** 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 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.
*/
export async function valhallaExcluding(
from: Waypoint,
to: Waypoint,
profile: Profile,
exclude: AvoidClass,
signal?: AbortSignal
): Promise<RouteAlternative | null> {
if (!valhallaAvailable()) return null;
const sample = usage(profile, 'alternatives', [from, to]);
const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal);
if (!signal?.aborted) countRoute({ ...sample, failed: !leg });
if (!leg || !legAvoids(leg, exclude)) return null;
return valhallaOffer(leg);
}
/**
* The offers for one leg of a day Valhalla routes, every one of them Valhalla's and
* weighed the way the day was, or null when it has nothing to say. Quietly offering
* OSRM's roads beside a Valhalla day is the mix that offered a road the rail was not on
* as the quicker one, and clicking it changed nothing.
*
* Where the router offers a single way, a driving leg gets the question the OSRM path
* asks too: the same drive with one more class left out, only of those the day 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.
*/
export async function valhallaDayAlternatives(
from: Waypoint,
to: Waypoint,
profile: Profile,
avoiding: AvoidClass[],
{ limit, signal }: { limit: number; signal?: AbortSignal }
): Promise<RouteAlternative[] | null> {
const sample = usage(profile, 'alternatives', [from, to]);
const answered = await valhallaDayAlternates(from, to, profile, avoiding, { count: Math.max(0, limit - 1), signal });
if (!signal?.aborted) countRoute({ ...sample, failed: !answered });
if (!answered?.length) return null;
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 || profile !== 'driving') return routes;
for (const cls of EXCLUDABLE_CLASSES) {
if (avoiding.includes(cls)) continue;
await valhallaSpacing(signal);
if (signal?.aborted) break;
const run = await valhallaDayRun([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;
}
+142 -37
View File
@@ -1,4 +1,11 @@
import { resolveValhallaProvider, type ValhallaRelayAction } from '@trek/shared'
import {
resolveValhallaProvider,
VALHALLA_COSTING,
VALHALLA_RELAY_MAX_LOCATIONS,
valhallaRoutesEveryDay,
type ValhallaProvider,
type ValhallaRelayAction,
} from '@trek/shared'
import { valhallaRelayRepo } from '../../repo/valhallaRelayRepo'
import { useSettingsStore } from '../../store/settingsStore'
import { decodePolyline } from './transitGeometry'
@@ -18,7 +25,8 @@ import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
* 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:
* On the public instance it is 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
@@ -33,8 +41,13 @@ import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
* 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.
* Which leaves exactly the shape this module serves there: two points, one avoidance,
* asked on demand, drawn as an offer next to the route rather than replacing it.
*
* The TREK services' Valhalla and an own one take twenty locations and no pace of one a
* second, so with either of them this engine routes every day (`valhallaDayRun`), and a
* day it cannot answer goes to OSRM whole. The third reason holds on every host, which
* is why one day is never routed by both.
*/
/** The public instance, matching how the OSRM defaults are shipped rather than configured. */
@@ -72,13 +85,6 @@ const RELAY_TIMEOUT_MS = 12_000
*/
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>
@@ -101,8 +107,9 @@ const AVOID_OPTION: Record<AvoidClass, string> = {
export type AvoidClass = RouteAvoidClass
/**
* Locations one request may carry. The eleventh answers
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400.
* Locations one request to the public instance may carry. The eleventh answers
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400. The TREK
* Valhalla and an own one take `VALHALLA_RELAY_MAX_LOCATIONS`.
*/
const MAX_LOCATIONS = 10
@@ -208,6 +215,13 @@ let relayRestingUntil = 0
* pipeline answers `exclude` on its own, which is exactly the fallback below.
*/
export function valhallaBase(): string | null {
const { provider, own } = currentProvider()
if (provider === 'custom') return own
if (provider === 'trek') return TREK_VALHALLA
return provider === 'fossgis' ? FOSSGIS_VALHALLA : null
}
function currentProvider(): { provider: ValhallaProvider | null; own: string } {
const settings = useSettingsStore.getState().settings
const own = settings.valhalla_base_url?.trim().replace(/\/+$/, '') ?? ''
const provider = resolveValhallaProvider({
@@ -217,9 +231,16 @@ export function valhallaBase(): string | null {
// Whether the TREK services can answer is the relay's to say; until it said no, ask it.
trekAvailable: Date.now() >= relayRestingUntil,
})
if (provider === 'custom') return own
if (provider === 'trek') return TREK_VALHALLA
return provider === 'fossgis' ? FOSSGIS_VALHALLA : null
return { provider, own }
}
/**
* Whether the days are routed here rather than by OSRM: with the TREK services or an own
* Valhalla. While the relay rests after a failure this reads as FOSSGIS, so the days go
* back to OSRM until it is asked again.
*/
export function valhallaRoutesDays(): boolean {
return valhallaRoutesEveryDay(currentProvider().provider)
}
/** True when this engine can be asked at all, so callers can skip building a request. */
@@ -230,6 +251,11 @@ export function valhallaAvailable(): boolean {
/** Hosts that have already proved they are not a Valhalla, by base URL. */
const notValhalla = new Set<string>()
/** Whether a question may go to this base: a Valhalla, and the public one only where it may stand in. */
function mayAsk(base: string | null, publicFallback: boolean): base is string {
return !!base && !notValhalla.has(base) && (publicFallback || base !== FOSSGIS_VALHALLA)
}
/**
* One leg with a road class weighted away, or null when it cannot be had.
*
@@ -271,11 +297,35 @@ export async function valhallaRun(
signal?: AbortSignal,
costingOptions: Readonly<CostingOptions> = {},
): Promise<ValhallaRun | null> {
const run = await chainRun(waypoints, profile, avoid, signal, { costingOptions, searchFilter: null })
const ask: RunAsk = { costingOptions, searchFilter: null, publicFallback: true }
const run = await chainRun(waypoints, profile, avoid, signal, ask)
if (!run || 'noPath' in run) return null
return run
}
/**
* A day of the planner or the road trip, routed here because this engine routes the
* days (`valhallaRoutesDays`) or because the day avoids something only it can avoid.
*
* A day that avoids nothing is never handed to the public instance: OSRM routes it
* there, as it always has, so where the TREK services cannot answer this is null and
* OSRM takes the whole day. A day that avoids something goes to FOSSGIS behind the
* relay as before, since OSRM cannot weigh a class away.
*/
export async function valhallaDayRun(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
): Promise<ValhallaRun | null> {
const run = await chainRun(waypoints, profile, avoid, signal, dayAsk(avoid))
return run && !('noPath' in run) ? run : null
}
function dayAsk(avoid: readonly AvoidClass[]): RunAsk {
return { costingOptions: {}, searchFilter: null, publicFallback: avoid.length > 0 }
}
/**
* CW-12: a chain walked on foot, every point bound off the boat lines. Measured
* on Venice: a stop by a vaporetto pier bound onto the boat line ended the
@@ -289,13 +339,24 @@ export function valhallaRunOnFoot(
costingOptions: Readonly<CostingOptions>,
searchFilter: Readonly<SearchFilter>,
): Promise<ValhallaRun | ValhallaNoPath | null> {
return chainRun(waypoints, 'walking', [], signal, { costingOptions, searchFilter })
return chainRun(waypoints, 'walking', [], signal, { costingOptions, searchFilter, publicFallback: true })
}
/** How a chain is asked for besides its profile and avoidance. */
interface RunAsk {
costingOptions: Readonly<CostingOptions>
searchFilter: Readonly<SearchFilter> | null
/** Whether FOSSGIS answers where the relay cannot; without it the question has no answer then. */
publicFallback: boolean
}
/**
* Locations one request carries: ten wherever it may end up at the public instance,
* twenty at the TREK Valhalla asked alone and at an own one.
*/
function locationsPerRequest(base: string, publicFallback: boolean): number {
const mayReachPublic = base === FOSSGIS_VALHALLA || (base === TREK_VALHALLA && publicFallback)
return mayReachPublic ? MAX_LOCATIONS : VALHALLA_RELAY_MAX_LOCATIONS
}
async function chainRun(
@@ -306,10 +367,10 @@ async function chainRun(
ask: RunAsk,
): Promise<ValhallaRun | ValhallaNoPath | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
if (!mayAsk(base, ask.publicFallback) || waypoints.length < 2) return null
const legs: ValhallaLeg[] = []
const chunks = chunkWaypoints(waypoints)
const chunks = chunkWaypoints(waypoints, locationsPerRequest(base, ask.publicFallback))
for (const [index, chunk] of chunks.entries()) {
if (signal?.aborted) return null
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
@@ -317,7 +378,8 @@ async function chainRun(
// 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, ask), signal)
const request = () => requestRun(base, chunk, profile, avoid, signal, ask)
const run = await askWithRetry(base, request, signal, ask.publicFallback)
// 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.
@@ -350,12 +412,12 @@ function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoin
})
}
/** 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]
/** Runs of at most `size` points, each starting on the one the previous ended at. */
function chunkWaypoints(waypoints: Waypoint[], size: number): Waypoint[][] {
if (waypoints.length <= size) 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))
for (let start = 0; start < waypoints.length - 1; start += size - 1) {
chunks.push(waypoints.slice(start, start + size))
}
return chunks
}
@@ -444,18 +506,22 @@ async function askRelay(action: ValhallaRelayAction, body: object, signal?: Abor
/**
* One question to a Valhalla, in its turn where the host is the public one. The relay
* is asked first where it is the base; where it cannot answer, the same question goes
* to FOSSGIS without a word, so a route never fails because of it.
* to FOSSGIS without a word, so a route never fails because of it. Without
* `publicFallback` it does not: the answer is then a 503 of this tab's own, and the
* caller asks OSRM.
*/
export async function valhallaPost(
base: string,
action: ValhallaRelayAction,
body: object,
signal?: AbortSignal,
{ publicFallback = true }: { publicFallback?: boolean } = {},
): Promise<Response> {
if (base === TREK_VALHALLA && Date.now() >= relayRestingUntil) {
const relayed = await askRelay(action, body, signal)
if (relayed) return relayed
}
if (base === TREK_VALHALLA && !publicFallback) return new Response(null, { status: 503 })
const host = base === TREK_VALHALLA ? FOSSGIS_VALHALLA : base
await valhallaTurn(host, signal)
return fetch(`${host}/${action}`, {
@@ -483,12 +549,20 @@ export function valhallaSpacing(signal?: AbortSignal): Promise<void> {
* 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.
* on a second ask, and asking anyway is the request the memo exists to save. Nor when
* the relay now rests and nothing may stand in for it: the second ask would not leave
* this tab, and OSRM is waiting for the day.
*/
async function askWithRetry<T>(base: string, ask: () => Promise<T | null>, signal?: AbortSignal): Promise<T | null> {
async function askWithRetry<T>(
base: string,
ask: () => Promise<T | null>,
signal: AbortSignal | undefined,
publicFallback: boolean,
): Promise<T | null> {
let answer = await ask()
for (let attempt = 0; !answer && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
if (notValhalla.has(base)) break
if (base === TREK_VALHALLA && !publicFallback && Date.now() < relayRestingUntil) break
await pause(CHUNK_PAUSE_MS, signal)
answer = await ask()
}
@@ -503,7 +577,7 @@ function routeBody(
costingOptions: Readonly<CostingOptions> = {},
searchFilter: Readonly<SearchFilter> | null = null,
): Record<string, unknown> {
const costing = COSTING[profile]
const costing = VALHALLA_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
@@ -524,9 +598,14 @@ function routeBody(
}
/** The parsed answer to one POST, `NO_PATH` when the router found no way, or null for every other failure. */
async function postRoute(base: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
async function postRoute(
base: string,
body: Record<string, unknown>,
signal: AbortSignal | undefined,
publicFallback: boolean,
): Promise<unknown> {
try {
const response = await valhallaPost(base, 'route', body, signal)
const response = await valhallaPost(base, 'route', body, signal, { publicFallback })
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:
@@ -628,9 +707,10 @@ async function requestRun(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal: AbortSignal | undefined,
{ costingOptions, searchFilter }: RunAsk,
{ costingOptions, searchFilter, publicFallback }: RunAsk,
): Promise<Omit<ValhallaRun, 'snapped'> | ValhallaNoPath | null> {
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions, searchFilter), signal)
const body = routeBody(waypoints, profile, avoid, costingOptions, searchFilter)
const data = await postRoute(base, body, signal, publicFallback)
if (data === NO_PATH) return NO_PATH
return data === null ? null : runFrom(data)
}
@@ -655,13 +735,38 @@ export async function valhallaAlternates(
signal?: AbortSignal,
costingOptions: Readonly<CostingOptions> = {},
): Promise<ValhallaLeg[] | null> {
const ask: RunAsk = { costingOptions, searchFilter: null, publicFallback: true }
return alternates([from, to], profile, avoid, { count, signal, ask })
}
/**
* The offers for one leg of a day `valhallaDayRun` routes, asked the way that day was:
* a leg OSRM would otherwise drive is never asked of the public instance.
*/
export function valhallaDayAlternates(
from: Waypoint,
to: Waypoint,
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
{ count, signal }: { count: number; signal?: AbortSignal },
): Promise<ValhallaLeg[] | null> {
return alternates([from, to], profile, avoid, { count, signal, ask: dayAsk(avoid) })
}
async function alternates(
[from, to]: [Waypoint, Waypoint],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
{ count, signal, ask }: { count: number; signal: AbortSignal | undefined; ask: RunAsk },
): Promise<ValhallaLeg[] | null> {
const { costingOptions, publicFallback } = ask
const base = valhallaBase()
if (!base || notValhalla.has(base)) return null
if (!mayAsk(base, publicFallback)) 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)
const data = await postRoute(base, body, signal, publicFallback)
return data === null ? null : alternatesFrom(data)
}, signal)
}, signal, publicFallback)
}
/**