diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 0ae42de61..4ad361ae8 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -8,7 +8,6 @@ "src/components/Map/MapView.tsx": 1385, "src/components/Map/MapViewGL.test.tsx": 2919, "src/components/Map/MapViewGL.tsx": 2218, - "src/components/Map/RouteCalculator.ts": 1063, "src/components/PDF/TripPDF.tsx": 1082, "src/components/Planner/DayDetailPanel.test.tsx": 2333, "src/components/Planner/DayPlanSidebar.test.tsx": 5349, diff --git a/client/src/components/Map/RouteCalculator.test.ts b/client/src/components/Map/RouteCalculator.test.ts index 41c08216b..6d40ada9b 100644 --- a/client/src/components/Map/RouteCalculator.test.ts +++ b/client/src/components/Map/RouteCalculator.test.ts @@ -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 = {}): 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') diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index e3844dfa6..d49222a52 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -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:/`): 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 { - 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 -/** - * 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 { - 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 { - 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 { - 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. */ diff --git a/client/src/components/Map/routeText.ts b/client/src/components/Map/routeText.ts new file mode 100644 index 000000000..5d79ca8c4 --- /dev/null +++ b/client/src/components/Map/routeText.ts @@ -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`; +} diff --git a/client/src/components/Map/valhallaDayRoute.test.ts b/client/src/components/Map/valhallaDayRoute.test.ts new file mode 100644 index 000000000..8efd0ace1 --- /dev/null +++ b/client/src/components/Map/valhallaDayRoute.test.ts @@ -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) => + 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'); + }); +}); diff --git a/client/src/components/Map/valhallaDayRoute.ts b/client/src/components/Map/valhallaDayRoute.ts new file mode 100644 index 000000000..628f1c929 --- /dev/null +++ b/client/src/components/Map/valhallaDayRoute.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts index f1823ddcc..a11e2aad9 100644 --- a/client/src/components/Map/valhallaRoute.ts +++ b/client/src/components/Map/valhallaRoute.ts @@ -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 @@ -101,8 +107,9 @@ const AVOID_OPTION: Record = { 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() +/** 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 = {}, ): Promise { - 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 { + 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, searchFilter: Readonly, ): Promise { - 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 searchFilter: Readonly | 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 { 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 { 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 { * 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(base: string, ask: () => Promise, signal?: AbortSignal): Promise { +async function askWithRetry( + base: string, + ask: () => Promise, + signal: AbortSignal | undefined, + publicFallback: boolean, +): Promise { 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 = {}, searchFilter: Readonly | null = null, ): Record { - 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, signal?: AbortSignal): Promise { +async function postRoute( + base: string, + body: Record, + signal: AbortSignal | undefined, + publicFallback: boolean, +): Promise { 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 | 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 = {}, ): Promise { + 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 { + 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 { + 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) } /** diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts index 6a744cd04..7c68f8664 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts @@ -1149,7 +1149,8 @@ describe('a leg asked for again on demand', () => { }) it('FE-ROADTRIP-ROUTES-047: a seam is asked under the card it arrives on, and a leg nothing weighs is OSRM\'s', () => { - act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'motorway' } })) }) + // FOSSGIS chosen: Valhalla only answers what OSRM cannot. + act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'motorway', routing_valhalla_provider: 'fossgis' } })) }) const days = [day(1, 1, { default_transport_mode: 'cycling' }), day(2, 2, { default_transport_mode: 'driving' })] const { result } = renderHook(() => useRoadtripRoutes(7, days, {} as AssignmentsMap)) @@ -1164,6 +1165,17 @@ describe('a leg asked for again on demand', () => { .toMatchObject({ mode: 'plugin:ev/fast', avoid: [], engine: 'plugin' }) }) + it('FE-ROADTRIP-ROUTES-047b: where Valhalla routes the days, a leg nothing weighs is Valhalla\'s too', () => { + act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, routing_valhalla_provider: 'trek' } })) }) + const days = [day(1, 1, { default_transport_mode: 'cycling' }), day(2, 2, { default_transport_mode: 'driving' })] + const { result } = renderHook(() => useRoadtripRoutes(7, days, {} as AssignmentsMap)) + + expect(result.current.legRouter?.(stopOn(1, 2, HAMBURG), stopOn(1, 3, LUENEBURG), 2)) + .toMatchObject({ mode: 'cycling', avoid: [], engine: 'valhalla' }) + expect(result.current.legRouter?.(stopOn(2, 0, BERLIN, { legMode: 'plugin:ev/fast' }), stopOn(2, 1, HAMBURG), 2)) + .toMatchObject({ engine: 'plugin' }) + }) + it('FE-ROADTRIP-ROUTES-048: a day the stand-in engine drove while avoiding says the avoidance did not happen', async () => { act(() => { useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_avoid: 'toll,ferry' } })) }) calculateRouteWithLegs.mockResolvedValue({ ...routed(1), avoidance: { asked: ['ferry', 'toll'], achieved: [], fellBack: true } })