mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): avoid tolls, motorways and ferries, through a second engine
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
This commit is contained in:
@@ -44,6 +44,7 @@ type Defaults = {
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -121,6 +122,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [valhallaBase, setValhallaBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -131,6 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setRoutingBase(data.routing_base_url || '')
|
||||
setValhallaBase(data.valhalla_base_url || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -154,6 +157,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'routing_base_url') setRoutingBase('')
|
||||
if (key === 'valhalla_base_url') setValhallaBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -409,6 +413,28 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* The second engine, next to the first because it is the same kind of decision
|
||||
and carries the same restart caveat. Only ever asked to leave a road class
|
||||
out of one leg, which is the one question the OSRM above refuses. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.valhallaBase')}
|
||||
<ResetButton field="valhalla_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={valhallaBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
|
||||
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
|
||||
placeholder="https://valhalla.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.valhallaBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
|
||||
@@ -797,6 +797,119 @@ describe('calculateAlternatives', () => {
|
||||
expect(asked).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The second engine, which is what made this whole branch produce anything on a
|
||||
// default install. Every case here overrides the 503 the shared handlers answer with,
|
||||
// so the OSRM path above stays the one the cases before this measure.
|
||||
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
const valhallaAnswer = (summary: Record<string, unknown>) => ({
|
||||
trip: {
|
||||
// The detour twoRoutes describes, re-encoded as the polyline6 Valhalla sends.
|
||||
legs: [{ shape: '_szadB_gjaR~po]_yqwC~po]~tpzA' }],
|
||||
summary: { length: 130, time: 4500, has_toll: false, has_highway: false, has_ferry: false, ...summary },
|
||||
},
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-050: one answer from OSRM is offered a second way by Valhalla', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
|
||||
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: false })))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
expect(routes[1].distance).toBeCloseTo(130000, 0)
|
||||
expect(routes[1].duration).toBe(4500)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-051: a road that still has the class is not offered as avoiding it', async () => {
|
||||
// use_highways: 0 is a weighting, so a leg with no way round the motorway comes back
|
||||
// on the motorway and says so. Offering that as "No motorway" would put a lie on the
|
||||
// map, so it is dropped and the leg keeps the single way OSRM found.
|
||||
//
|
||||
// Asserted on the result rather than on which excludes OSRM was asked: by this point
|
||||
// `excludeUnsupported` has already recorded the public host from the case above, and
|
||||
// that Set is module state for the lifetime of the file.
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}),
|
||||
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true, has_ferry: true })))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52.5, lng: 11.5 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].avoids).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-052: a ferry-free way is offered, which OSRM alone never managed', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}),
|
||||
http.post(VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { costing_options: { auto: Record<string, number> } }
|
||||
// Only the ferry question gets a usable answer, so the first two fall through.
|
||||
if (!('use_ferry' in body.costing_options.auto)) {
|
||||
return HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true }))
|
||||
}
|
||||
return HttpResponse.json(valhallaAnswer({ has_ferry: false }))
|
||||
})
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 54, lng: 10 }, { lat: 55, lng: 12 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('ferry')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-054: the same road from the other engine is not offered as a second one', async () => {
|
||||
// The reason the geometry decides rather than the numbers. Valhalla prices roads
|
||||
// differently, so on a long leg the SAME road comes back kilometres apart in both
|
||||
// length and time — here 13 km and 5 minutes, which is the measured spread between
|
||||
// the two engines and comfortably past the 200 m the numbers used to allow. Judged
|
||||
// on those it reads as a discovery and puts a second blue line on top of the first.
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
|
||||
http.post(VALHALLA, () => HttpResponse.json({
|
||||
trip: {
|
||||
// twoRoutes.routes[0]'s own line, re-encoded: the identical road.
|
||||
legs: [{ shape: '_szadB_gjaR~po]_qo]~po]_qo]' }],
|
||||
summary: { length: 113, time: 3900, has_toll: false, has_highway: false, has_ferry: false },
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
})
|
||||
|
||||
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' } }))
|
||||
server.use(
|
||||
http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
|
||||
}),
|
||||
http.post(VALHALLA, () => { valhallaCalls++; return HttpResponse.json(valhallaAnswer({})) })
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 12 })
|
||||
|
||||
expect(valhallaCalls).toBe(0)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
|
||||
// OSRM reports these in every answer and TREK threw them away since the first route
|
||||
// was drawn. Without them a place set back from the road looks like it is on the
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { countRoute } from './routeUsageCounter'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute'
|
||||
import type { RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
@@ -392,23 +393,55 @@ export async function calculateSegments(
|
||||
*/
|
||||
export async function calculateRouteWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
{ signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {}
|
||||
{ signal, profile = 'driving', tripId, dayId, avoid = [] }: {
|
||||
signal?: AbortSignal
|
||||
profile?: RouteProfileKey
|
||||
tripId?: number | string | null
|
||||
dayId?: number | null
|
||||
/** Road classes to weight away. Only the driving profile has any, and only Valhalla can. */
|
||||
avoid?: readonly AvoidClass[]
|
||||
} = {}
|
||||
): Promise<RouteWithLegs> {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||
}
|
||||
|
||||
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
|
||||
? [...avoid].sort()
|
||||
: []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
// The cached result carries formatted leg distances, so the active distance unit is
|
||||
// part of the key — otherwise switching km↔mi would return stale text (#1300).
|
||||
// A plugin route is trip-/day-specific (it may return different charging stops for
|
||||
// the same coordinates on a different day), so its key includes tripId/dayId;
|
||||
// the built-in OSRM profiles are context-free and leave those out.
|
||||
//
|
||||
// The avoidance belongs in the key for two reasons pointing opposite ways. Without it
|
||||
// turning the switch on returns the tolled route out of cache in under a millisecond,
|
||||
// so nothing on screen changes and it reads as a broken toggle. And the leak the other
|
||||
// way is worse: this Map is shared with the ordinary day planner, the studio and the
|
||||
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
|
||||
// under the plain key would hand them a road nobody drives and a second engine's
|
||||
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
|
||||
// also separates the two engines.
|
||||
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
|
||||
const cached = routeCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
if (avoiding.length) {
|
||||
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
|
||||
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
|
||||
// day drawn rather than blank, and `avoidance` stays absent on that result so the
|
||||
// rail can say the avoidance did not happen instead of implying it did.
|
||||
if (routed) {
|
||||
cacheRoute(cacheKey, routed)
|
||||
return routed
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
|
||||
// and normalizes its answer; null means the provider failed or refused, and the
|
||||
// throw makes callers fall back to straight lines exactly like an OSRM outage.
|
||||
@@ -486,12 +519,73 @@ export async function calculateRouteWithLegs(
|
||||
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
routeCache.set(cacheKey, result)
|
||||
cacheRoute(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Store a route and drop the oldest once the cache is over its cap. */
|
||||
function cacheRoute(key: string, route: RouteWithLegs): void {
|
||||
routeCache.set(key, route)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -554,7 +648,7 @@ export interface RouteAlternative {
|
||||
/** Where this route differs most from the first one — the point that would pin it. */
|
||||
divergence: { lat: number; lng: number } | null
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: 'motorway' | 'toll'
|
||||
avoids?: AvoidClass
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -568,11 +662,39 @@ export interface RouteAlternative {
|
||||
*
|
||||
* 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'] as const
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
|
||||
|
||||
/** Two lines are the same road if their length and their duration agree closely enough. */
|
||||
/**
|
||||
* How far two lines may stray from each other and still be the same road, in kilometres.
|
||||
*
|
||||
* Parallel carriageways, a different slip road onto the same motorway and the noise
|
||||
* between two engines' idea of where a road is all sit well under this. A way somebody
|
||||
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
|
||||
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
|
||||
*/
|
||||
const SAME_ROAD_KM = 0.5
|
||||
|
||||
/**
|
||||
* Two lines are the same road.
|
||||
*
|
||||
* Length and duration used to decide this, and stopped being enough when a second engine
|
||||
* started answering. Valhalla prices roads differently — measured over ten European legs
|
||||
* its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same
|
||||
* road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery.
|
||||
* Two identical blue lines on the map is exactly what that looks like.
|
||||
*
|
||||
* The geometry does not care which engine drew it, so it decides whenever there is one.
|
||||
* The numbers stay as the fallback for a line with no shape to compare.
|
||||
*/
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
const stray = furthestFrom(b.coordinates, a.coordinates)
|
||||
if (stray) return stray.km < SAME_ROAD_KM
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
@@ -603,22 +725,76 @@ const excludeUnsupported = new Set<string>()
|
||||
* particular question had no answer.
|
||||
*/
|
||||
async function routeExcluding(
|
||||
coords: string,
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: string,
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
// Valhalla first, because on a default install it is the only one of the two that
|
||||
// can answer at all. It weights the class away instead of banning it, so it also
|
||||
// answers on a leg where the class cannot be avoided — and says so, which is why the
|
||||
// result is thrown away below unless the road really came back without it.
|
||||
const weighted = await valhallaExcluding(from, to, profile, exclude, signal)
|
||||
if (weighted) return weighted
|
||||
|
||||
// OSRM has a ferry class too, but on a leg where the ferry IS the crossing it answers
|
||||
// NoRoute rather than driving round, so there is nothing to offer and no point paying
|
||||
// a request to be told so.
|
||||
if (exclude === 'ferry') return null
|
||||
return osrmExcluding(from, to, profile, exclude, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same leg from Valhalla, weighted away from one class, or null.
|
||||
*
|
||||
* The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so
|
||||
* between two points with no untolled connection the answer is still a tolled road.
|
||||
* Offering that as "No tolls" would put a lie on the map, so it is dropped and the
|
||||
* OSRM attempt gets its turn.
|
||||
*/
|
||||
async function valhallaExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<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 {
|
||||
coordinates: leg.coordinates,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
divergence: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The same leg from OSRM with the class excluded outright, or null. */
|
||||
async function osrmExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
const base = routeBaseFor(profile)
|
||||
if (excludeUnsupported.has(base)) return null
|
||||
try {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
|
||||
// This one is handed its points already encoded, so they are read back out for the
|
||||
// counter rather than threaded through a second parameter nobody else needs.
|
||||
const points = coords.split(';').map(pair => {
|
||||
const [lng, lat] = pair.split(',').map(Number)
|
||||
return { lat, lng }
|
||||
})
|
||||
const response = await routedFetch(url, signal, 'alternatives', profile, points)
|
||||
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
|
||||
if (!response.ok) {
|
||||
// 400 is the router saying the parameter itself is not available here, which is
|
||||
// true of every leg from now on. Anything else is about this request alone.
|
||||
@@ -675,19 +851,18 @@ export async function calculateAlternatives(
|
||||
|
||||
// Nothing but the router's own preference came back, which is what it answers on most
|
||||
// long legs. Rather than reporting "only one sensible way", ask a different question:
|
||||
// the same drive without the motorway, and failing that without the tolls. Tried one at
|
||||
// a time and stopped as soon as one lands, so a leg that does have a second road costs
|
||||
// one extra request rather than three.
|
||||
// the same drive without the motorway, then without the tolls, then without a ferry.
|
||||
// Tried one at a time and stopped as soon as one lands, so a leg that does have a
|
||||
// second road costs one extra request rather than three.
|
||||
//
|
||||
// Worth knowing before reading further: on the two public hosts TREK ships with, this
|
||||
// never produces anything. Their car profile is built without excludable classes and
|
||||
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
|
||||
// way to drive them. It works against a self-hosted OSRM built through the MLD
|
||||
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
|
||||
// This used to produce nothing at all on a default install: OSRM's `exclude` is HTTP
|
||||
// 400 on both public hosts, whose car profile is built without excludable classes. It
|
||||
// now asks Valhalla first, where avoidance is a runtime weighting, and only falls back
|
||||
// to OSRM's `exclude` for an instance running its own MLD-built router.
|
||||
if (routes.length < 2 && profile === 'driving') {
|
||||
for (const exclude of EXCLUDABLE_CLASSES) {
|
||||
if (signal?.aborted) break
|
||||
const detour = await routeExcluding(coords, profile, exclude, signal)
|
||||
const detour = await routeExcluding(from, to, profile, exclude, signal)
|
||||
if (detour && !routes.some(r => sameRoad(r, detour))) {
|
||||
detour.avoids = exclude
|
||||
routes.push(detour)
|
||||
@@ -705,22 +880,43 @@ export async function calculateAlternatives(
|
||||
|
||||
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
|
||||
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
|
||||
const furthest = furthestFrom(line, reference)
|
||||
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The same point, plus how far off it actually is.
|
||||
*
|
||||
* The search itself stays in squared degrees with a cosine on the longitude: it only has
|
||||
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
|
||||
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
|
||||
* real distance rather than a rank, and half a degree is a different number of metres in
|
||||
* Andalusia than in Lapland.
|
||||
*/
|
||||
function furthestFrom(
|
||||
line: [number, number][],
|
||||
reference: [number, number][],
|
||||
): { lat: number; lng: number; km: number } | null {
|
||||
if (!line.length || !reference.length) return null
|
||||
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
|
||||
// full cross product of two thousand-point lines is not worth the milliseconds.
|
||||
const step = Math.max(1, Math.floor(reference.length / 200))
|
||||
let best: { lat: number; lng: number } | null = null
|
||||
let bestNeighbour: [number, number] | null = null
|
||||
let bestDist = -1
|
||||
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
|
||||
const [lat, lng] = line[i]
|
||||
let nearest = Infinity
|
||||
let nearestPoint: [number, number] | null = null
|
||||
for (let j = 0; j < reference.length; j += step) {
|
||||
const dLat = lat - reference[j][0]
|
||||
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
|
||||
const d = dLat * dLat + dLng * dLng
|
||||
if (d < nearest) nearest = d
|
||||
if (d < nearest) { nearest = d; nearestPoint = reference[j] }
|
||||
}
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
|
||||
}
|
||||
return best
|
||||
if (!best) return null
|
||||
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
|
||||
return { ...best, km }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
/**
|
||||
* A real polyline6, encoded from three known points and verified by decoding it back.
|
||||
* Precision matters here more than anywhere else in the file: read as polyline5 the
|
||||
* first vertex would be 535.511, which is not a latitude, and a wrong guess draws a
|
||||
* line into the sea rather than failing.
|
||||
*/
|
||||
const SHAPE = 'w~nceBg}}`RgkPwjr@~fn~@oirmE'
|
||||
const SHAPE_POINTS: [number, number][] = [[53.5511, 9.9937], [53.56, 10.02], [52.52, 13.405]]
|
||||
|
||||
const answer = (summary: Record<string, unknown> = {}) => ({
|
||||
trip: {
|
||||
legs: [{ shape: SHAPE }],
|
||||
summary: {
|
||||
has_time_restrictions: false,
|
||||
has_toll: false,
|
||||
has_highway: true,
|
||||
has_ferry: false,
|
||||
// Asked in kilometres, so this is 295.7 km and 191 minutes — the real
|
||||
// Hamburg-to-Berlin answer, which the adapter has to hand on in metres and seconds.
|
||||
length: 295.7,
|
||||
time: 11460,
|
||||
...summary,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const setSettings = (patch: Record<string, string>) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, ...patch } }))
|
||||
|
||||
afterEach(() => {
|
||||
setSettings({ routing_base_url: '', valhalla_base_url: '' })
|
||||
})
|
||||
|
||||
const HAMBURG = { lat: 53.5511, lng: 9.9937 }
|
||||
const BERLIN = { lat: 52.52, lng: 13.405 }
|
||||
|
||||
describe('valhallaBase', () => {
|
||||
it('VALHALLA-BASE-001: falls back to the public instance when nothing is configured', () => {
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
expect(valhallaAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-002: uses the configured instance and drops its trailing slashes', () => {
|
||||
setSettings({ valhalla_base_url: 'https://valhalla.example.org//' })
|
||||
expect(valhallaBase()).toBe('https://valhalla.example.org')
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-003: asks nothing when the instance runs its own OSRM', () => {
|
||||
// An operator who filled in their own router chose against the public hosts, usually
|
||||
// because the instance is not supposed to reach third parties at all. Adding one they
|
||||
// never asked for would undo that, and their MLD-built OSRM answers exclude itself.
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
expect(valhallaBase()).toBeNull()
|
||||
expect(valhallaAvailable()).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-004: an own Valhalla wins over an own OSRM', () => {
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org', valhalla_base_url: 'https://v.example.org' })
|
||||
expect(valhallaBase()).toBe('https://v.example.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runFrom', () => {
|
||||
const total = (data: unknown) => runFrom(data)?.total ?? null
|
||||
|
||||
it('VALHALLA-PARSE-001: decodes the shape as polyline6 and converts km to metres', () => {
|
||||
const leg = total(answer())
|
||||
expect(leg).not.toBeNull()
|
||||
expect(leg!.coordinates).toEqual(SHAPE_POINTS)
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(leg!.duration).toBe(11460)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-002: carries the flags through as booleans', () => {
|
||||
const leg = total(answer({ has_toll: true, has_ferry: true, has_highway: false }))
|
||||
expect(leg).toMatchObject({ hasToll: true, hasFerry: true, hasHighway: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-003: a missing flag reads as absent rather than unknown', () => {
|
||||
// The public instance omits a flag it has nothing to say about, and the caller turns
|
||||
// this into a label. Undefined has to become false, not a truthy object.
|
||||
const leg = total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 1, time: 60 } } })
|
||||
expect(leg).toMatchObject({ hasToll: false, hasHighway: false, hasFerry: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-004: keeps a chain divided leg by leg, and adds it up as well', () => {
|
||||
// What a request with a via really answers: one summary per pair of stops. The
|
||||
// schedule is built from those, so flattening them into a total would lose the
|
||||
// arrival time at every stop but the last.
|
||||
const chain = {
|
||||
trip: {
|
||||
legs: [
|
||||
{ shape: SHAPE, summary: { length: 100, time: 3600, has_toll: false, has_highway: true, has_ferry: false } },
|
||||
{ shape: SHAPE, summary: { length: 50, time: 1800, has_toll: true, has_highway: false, has_ferry: false } },
|
||||
],
|
||||
summary: { length: 150, time: 5400, has_toll: true, has_highway: true, has_ferry: false },
|
||||
},
|
||||
}
|
||||
const run = runFrom(chain)!
|
||||
expect(run.legs.map(l => l.distance)).toEqual([100000, 50000])
|
||||
expect(run.legs.map(l => l.hasToll)).toEqual([false, true])
|
||||
expect(run.total.distance).toBe(150000)
|
||||
expect(run.total.duration).toBe(5400)
|
||||
// One tolled leg makes the drive tolled. `every` here would call a day with a
|
||||
// single toll booth in it toll-free.
|
||||
expect(run.total.hasToll).toBe(true)
|
||||
expect(run.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-007: several legs with no summaries of their own are refused', () => {
|
||||
// There is no honest way to divide one total between them, and a guess would reach
|
||||
// the user as a wrong arrival time rather than as a missing one.
|
||||
const two = { trip: { legs: [{ shape: SHAPE }, { shape: SHAPE }], summary: { length: 2, time: 120 } } }
|
||||
expect(runFrom(two)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-005: an error body is not a leg', () => {
|
||||
// What a 400 carries: no trip at all. Parsing it as one would produce a route of
|
||||
// NaN kilometres rather than the null every caller reads as "no second way".
|
||||
expect(total({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
|
||||
expect(total({ trip: { legs: [], summary: { length: 1, time: 1 } } })).toBeNull()
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }] } })).toBeNull()
|
||||
expect(total(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-006: a summary without numbers is not a leg', () => {
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 'far', time: 60 } } })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legAvoids', () => {
|
||||
const leg = (flags: Partial<{ hasToll: boolean; hasHighway: boolean; hasFerry: boolean }>) => ({
|
||||
coordinates: SHAPE_POINTS, distance: 1, duration: 1,
|
||||
hasToll: false, hasHighway: false, hasFerry: false, ...flags,
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-001: reads the answer, not the request', () => {
|
||||
// use_tolls: 0 is a weighting. Between two points with no untolled connection the
|
||||
// answer is still a tolled road, honestly flagged, and labelling that "No tolls"
|
||||
// would put a lie on the map.
|
||||
expect(legAvoids(leg({ hasToll: false }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasToll: true }), 'toll')).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-002: each class reads its own flag', () => {
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'motorway')).toBe(false)
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasFerry: true }), 'ferry')).toBe(false)
|
||||
expect(legAvoids(leg({ hasFerry: false }), 'ferry')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRouteAvoiding', () => {
|
||||
it('VALHALLA-REQ-001: posts the avoidance as a costing option under the right profile', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(body).toMatchObject({
|
||||
costing: 'auto',
|
||||
costing_options: { auto: { use_tolls: 0 } },
|
||||
directions_options: { units: 'kilometers' },
|
||||
})
|
||||
// The radius is what makes a place in a pedestrian zone routable at all: without it
|
||||
// Vienna's Stephansdom answers error 442 and the whole leg is lost.
|
||||
expect(body!.locations).toEqual([
|
||||
{ lat: 53.5511, lon: 9.9937, radius: 50 },
|
||||
{ lat: 52.52, lon: 13.405, radius: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-002: each class maps to its own costing option', async () => {
|
||||
const seen: Record<string, unknown>[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
seen.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'ferry')
|
||||
expect(seen.map(b => b.costing_options)).toEqual([
|
||||
{ auto: { use_highways: 0 } },
|
||||
{ auto: { use_ferry: 0 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-003: walking and cycling get their own costing models', async () => {
|
||||
const seen: string[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { costing: string }
|
||||
seen.push(body.costing)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'walking', 'ferry')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')
|
||||
expect(seen).toEqual(['pedestrian', 'bicycle'])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-004: a refusal is a null, not a throw', async () => {
|
||||
// Every failure means the same thing to the caller — there is no second way to
|
||||
// show — so a 400 for an unroutable point and a 429 for the rate limit both end
|
||||
// here rather than as an error over a map that is drawing fine.
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, () =>
|
||||
HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 })
|
||||
)
|
||||
)
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-005: a host that is not a Valhalla is only asked once', async () => {
|
||||
let calls = 0
|
||||
setSettings({ valhalla_base_url: 'https://not-valhalla.example.org' })
|
||||
server.use(
|
||||
http.post('https://not-valhalla.example.org/route', () => {
|
||||
calls++
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
// A 404 is about the URL rather than about this leg, so remembering it saves every
|
||||
// later leg a request against a host that will never answer.
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-006: asks nothing at all when the instance has its own OSRM', async () => {
|
||||
let calls = 0
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(answer()) }))
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRun over a whole day', () => {
|
||||
/** A chain answer with one summary per leg, which is what a real request returns. */
|
||||
const chain = (legCount: number) => ({
|
||||
trip: {
|
||||
legs: Array.from({ length: legCount }, () => ({
|
||||
shape: SHAPE,
|
||||
summary: { length: 10, time: 600, has_toll: false, has_highway: true, has_ferry: false },
|
||||
})),
|
||||
summary: { length: 10 * legCount, time: 600 * legCount, has_toll: false, has_highway: true, has_ferry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const stops = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 }))
|
||||
|
||||
it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
calls++
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(6), 'driving', [])
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(run!.legs).toHaveLength(5)
|
||||
expect(run!.total.distance).toBe(50000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => {
|
||||
// Ten locations is hard: the eleventh answers error 150. Split runs overlap by one
|
||||
// point, which is exact rather than approximate — measured over a six-stop day,
|
||||
// [0..3] plus [3..5] came back within two metres of the same day asked whole.
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
// Fourteen stops: the first run takes ten, the second starts on the tenth again and
|
||||
// takes the remaining five. Nine pairs plus four.
|
||||
expect(sizes).toEqual([10, 5])
|
||||
// Thirteen pairs across two requests, none lost and none counted twice.
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
expect(run!.total.distance).toBe(130000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-003: never sends more than ten locations, however long the day', async () => {
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await valhallaRun(stops(28), 'driving', [])
|
||||
|
||||
expect(Math.max(...sizes)).toBeLessThanOrEqual(10)
|
||||
expect(sizes.reduce((sum, n) => sum + n - 1, 0)).toBe(27)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-004: a piece that will not answer loses the whole day rather than half of it', async () => {
|
||||
// Half a day of legs is worse than none. The schedule chains leg times blindly and
|
||||
// cannot tell that some are missing, so it would print a complete, plausible,
|
||||
// silently wrong timetable instead of leaving a visible gap.
|
||||
//
|
||||
// The SECOND CHUNK fails, not the second request: one attempt is retried, so failing
|
||||
// by call number would just be answered on the retry.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
if (body.locations.length < 10) {
|
||||
return HttpResponse.json({ error_code: 442, error: 'No path' }, { status: 400 })
|
||||
}
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await expect(valhallaRun(stops(14), 'driving', ['toll'])).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-007: a piece that fails once is asked again rather than losing the day', async () => {
|
||||
// The caller retries the whole task, so without this a rate limit on the last piece
|
||||
// of a split day throws away the pieces that already answered and asks for all of
|
||||
// them again — three times over, against a host that allows one request a second.
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
calls++
|
||||
if (calls === 2) return new HttpResponse(null, { status: 429 })
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
// First piece, second piece refused, second piece again.
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
|
||||
await expect(valhallaRun(stops(1), 'driving', [])).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-006: an answer with the wrong number of legs is refused', async () => {
|
||||
// A day whose legs do not match its stops cannot be assigned to the rail, and
|
||||
// guessing which stop is missing would put the wrong time against the right place.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(chain(2))))
|
||||
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { decodePolyline } from './transitGeometry'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* Valhalla, the second routing engine, asked only the questions OSRM cannot answer.
|
||||
*
|
||||
* The shipped OSRM hosts are built without excludable road classes: their car profile
|
||||
* says so in its own Lua ("no memory for classes on demand server"), and `exclude=`
|
||||
* comes back as HTTP 400 on every leg. So the one thing a road tripper keeps asking
|
||||
* for — drive this without the tolls — has never worked on a default install.
|
||||
*
|
||||
* FOSSGIS runs a public Valhalla on the whole planet next to it, keyless, and there
|
||||
* avoidance is a runtime weighting rather than a profile that has to be rebuilt. This
|
||||
* module is the adapter: same question, different engine, answer shaped like the OSRM
|
||||
* one so the callers do not have to care which engine spoke.
|
||||
*
|
||||
* Deliberately NOT a replacement for the OSRM path. Three measured reasons:
|
||||
* - it caps at ten locations, and following a track sends a day's stops plus up to
|
||||
* nine anchors, so the bundled requests would start failing;
|
||||
* - it refuses a point it cannot bind to a road, where OSRM still snaps one. The
|
||||
* Stephansdom, in a pedestrian zone, answers `No path could be found for input`
|
||||
* until a search radius is allowed;
|
||||
* - it prices roads differently. Over ten European legs its drive times sat 5.4 %
|
||||
* above OSRM's on average and 36 % above on one, and those times feed the arrival
|
||||
* clocks of a road trip day. Mixing engines inside one chain would move times
|
||||
* nobody asked to move.
|
||||
*
|
||||
* Which leaves exactly the shape this module serves: two points, one avoidance, asked
|
||||
* on demand, drawn as an offer next to the route rather than replacing it.
|
||||
*/
|
||||
|
||||
/** The public instance, matching how the OSRM defaults are shipped rather than configured. */
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de'
|
||||
|
||||
/**
|
||||
* How far Valhalla may look for a road under a point, in metres.
|
||||
*
|
||||
* Zero is its default and it is the wrong default for TREK's data, which is full of
|
||||
* museums, viewpoints and restaurants that sit inside a pedestrian zone. Measured on
|
||||
* Vienna's Stephansdom: without a radius the whole route answers error 442, with 50 it
|
||||
* routes, and 200 changed nothing further. So 50 buys the fix and nothing beyond it.
|
||||
*/
|
||||
const SNAP_RADIUS_M = 50
|
||||
|
||||
/** TREK's profile names against Valhalla's costing models. */
|
||||
const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'auto',
|
||||
walking: 'pedestrian',
|
||||
cycling: 'bicycle',
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caller can ask to be left out, against the costing option that asks for it.
|
||||
*
|
||||
* Every one of these is a weighting from 0 to 1, not a ban — which is the whole reason
|
||||
* this engine is worth having. Asked to avoid tolls between New York and Boston it
|
||||
* still returns a tolled road, because there is no untolled way, where OSRM's `exclude`
|
||||
* would refuse to answer at all. The caller has to read the answer to find out what it
|
||||
* got, which is what `hasToll` and friends are for.
|
||||
*/
|
||||
const AVOID_OPTION: Record<AvoidClass, string> = {
|
||||
motorway: 'use_highways',
|
||||
toll: 'use_tolls',
|
||||
ferry: 'use_ferry',
|
||||
}
|
||||
|
||||
/** The same three classes the rest of the app names; aliased so this file reads on its own. */
|
||||
export type AvoidClass = RouteAvoidClass
|
||||
|
||||
/**
|
||||
* Locations one request may carry. The eleventh answers
|
||||
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400.
|
||||
*/
|
||||
const MAX_LOCATIONS = 10
|
||||
|
||||
/**
|
||||
* Pause between the pieces of a split day, in milliseconds.
|
||||
*
|
||||
* The public instance allows about one request a second per client, and a day long
|
||||
* enough to be split is exactly the case that would fire two back to back. Matches the
|
||||
* 1100 ms the road trip rail already leaves between its own days.
|
||||
*/
|
||||
const CHUNK_PAUSE_MS = 1100
|
||||
|
||||
/**
|
||||
* Extra attempts for one piece of a split day.
|
||||
*
|
||||
* One, not a ladder: the common failure here is the public instance's rate limit, which
|
||||
* a single pause clears, and anything a second attempt cannot fix is not going to be
|
||||
* fixed by a fourth while somebody waits for their map.
|
||||
*/
|
||||
const CHUNK_RETRIES = 1
|
||||
|
||||
/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */
|
||||
export interface ValhallaLeg {
|
||||
coordinates: [number, number][]
|
||||
/** Metres. Valhalla is asked in kilometres and converted here. */
|
||||
distance: number
|
||||
/** Seconds. */
|
||||
duration: number
|
||||
/** What the route ACTUALLY has, as opposed to what was asked for. */
|
||||
hasToll: boolean
|
||||
hasHighway: boolean
|
||||
hasFerry: boolean
|
||||
}
|
||||
|
||||
/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */
|
||||
export interface ValhallaRun {
|
||||
legs: ValhallaLeg[]
|
||||
total: ValhallaLeg
|
||||
/**
|
||||
* Where each waypoint actually met the road.
|
||||
*
|
||||
* Reconstructed from the shape rather than read from a field, because Valhalla echoes
|
||||
* the coordinates it was GIVEN in `trip.locations` and never says where it bound them.
|
||||
* The ends of a leg's line are those points: measured against Füssen, its first vertex
|
||||
* came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same
|
||||
* request to the digit. Without this the dashed kerb-to-door spur from #2273 would
|
||||
* quietly disappear from every day routed by this engine.
|
||||
*/
|
||||
snapped: SnappedWaypoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance's Valhalla, or null when it should not ask one.
|
||||
*
|
||||
* Blank falls back to the public instance, the same reading `routing_base_url` gets —
|
||||
* with one condition. An operator who filled in their OWN router chose deliberately
|
||||
* against the public hosts, usually because their instance is not supposed to talk to
|
||||
* third parties at all, and quietly adding a second public host they never asked for
|
||||
* would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD
|
||||
* pipeline answers `exclude` on its own, which is exactly the fallback below.
|
||||
*/
|
||||
export function valhallaBase(): string | null {
|
||||
const settings = useSettingsStore.getState().settings
|
||||
const configured = settings.valhalla_base_url?.trim()
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA
|
||||
}
|
||||
|
||||
/** True when this engine can be asked at all, so callers can skip building a request. */
|
||||
export function valhallaAvailable(): boolean {
|
||||
return valhallaBase() !== null
|
||||
}
|
||||
|
||||
/** Hosts that have already proved they are not a Valhalla, by base URL. */
|
||||
const notValhalla = new Set<string>()
|
||||
|
||||
/**
|
||||
* One leg with a road class weighted away, or null when it cannot be had.
|
||||
*
|
||||
* Null for every failure, deliberately: this is an offer next to a route that already
|
||||
* exists, so a host that is down, a rate limit, a point in a field and a genuinely
|
||||
* connected-only-by-motorway leg all mean the same thing to the caller — there is no
|
||||
* second way to show. The one failure worth remembering is a host that is not a
|
||||
* Valhalla at all, because that is true of every leg from now on rather than this one.
|
||||
*/
|
||||
export async function valhallaRouteAvoiding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaLeg | null> {
|
||||
const run = await valhallaRun([from, to], profile, [avoid], signal)
|
||||
return run ? run.total : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain of waypoints, one leg per pair, or null when it cannot be had.
|
||||
*
|
||||
* Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled
|
||||
* into one request precisely so it appears at once rather than trickling in — so a day
|
||||
* over the cap is cut into runs that overlap by one point and the legs are concatenated.
|
||||
* That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5]
|
||||
* came back within two metres and under a second of the same day asked whole, because
|
||||
* the shared point is routed to and from identically.
|
||||
*
|
||||
* The pieces go out one after another with a pause, since a day long enough to need
|
||||
* splitting is exactly the one that would otherwise fire two requests inside a second
|
||||
* at a host that allows one.
|
||||
*/
|
||||
export async function valhallaRun(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaRun | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
const chunks = chunkWaypoints(waypoints)
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
if (signal?.aborted) return null
|
||||
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
|
||||
// Retried per piece rather than per day. The caller retries the whole task, so a
|
||||
// rate limit on the last piece of a split day would otherwise throw away the pieces
|
||||
// that already answered and ask for all of them again — three times over, against a
|
||||
// host that allows one request a second.
|
||||
let run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
|
||||
// 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.
|
||||
if (notValhalla.has(base)) break
|
||||
await pause(CHUNK_PAUSE_MS, signal)
|
||||
run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
}
|
||||
// A day is one answer or none. Half a day of legs would be worse than no answer:
|
||||
// the schedule chains leg times blindly and cannot tell that it is missing some, so
|
||||
// it would print a complete, plausible, wrong timetable rather than a gap.
|
||||
if (!run) return null
|
||||
legs.push(...run.legs)
|
||||
}
|
||||
if (legs.length !== waypoints.length - 1) return null
|
||||
return { legs, total: joinLegs(legs), snapped: snappedFrom(legs, waypoints) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each waypoint met the road, taken off the ends of the legs that touch it.
|
||||
*
|
||||
* A leg's line starts at the bound position of its first stop and ends at the bound
|
||||
* position of its second, so stop i is the head of leg i, and the last stop is the tail
|
||||
* of the last leg. The distance is measured here rather than reported, the same recovery
|
||||
* `readSnapped` already applies when OSRM leaves the field out.
|
||||
*/
|
||||
function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoint[] {
|
||||
return waypoints.map((waypoint, i) => {
|
||||
const line = i < legs.length ? legs[i].coordinates : legs[legs.length - 1].coordinates
|
||||
const at = i < legs.length ? line[0] : line[line.length - 1]
|
||||
const asked: [number, number] = [waypoint.lat, waypoint.lng]
|
||||
return {
|
||||
asked,
|
||||
at,
|
||||
meters: haversineKm(waypoint, { lat: at[0], lng: at[1] }) * 1000,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs of at most ten points, each starting on the one the previous ended at. */
|
||||
function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] {
|
||||
if (waypoints.length <= MAX_LOCATIONS) return [waypoints]
|
||||
const chunks: Waypoint[][] = []
|
||||
for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) {
|
||||
chunks.push(waypoints.slice(start, start + MAX_LOCATIONS))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function pause(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = {}
|
||||
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||
const body = {
|
||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||
costing,
|
||||
costing_options: { [costing]: options },
|
||||
directions_options: { units: 'kilometers' },
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/route`, {
|
||||
method: 'POST',
|
||||
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a
|
||||
// client to identify itself with. It costs a preflight, which is answered.
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
// 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake,
|
||||
// or a proxy that swallowed the path. Every other status is about this request:
|
||||
// 400 carries the real refusals (unroutable point, over 1500 km apart) and 429
|
||||
// is the public instance's one-per-second.
|
||||
if (response.status === 404 || response.status === 405) notValhalla.add(base)
|
||||
return null
|
||||
}
|
||||
return runFrom(await response.json())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Several legs read as one, the way a caller that only wants the whole drive sees it.
|
||||
*
|
||||
* The seam matters more than it looks. Every leg ends where the next begins, so a plain
|
||||
* concatenation repeats that vertex — and this line is not only drawn: a via dropped on
|
||||
* the route is projected onto it to work out which leg it belongs after, and that index
|
||||
* is written to the database. A repeated point can put the via on the wrong leg, which
|
||||
* is a stored wrong answer rather than an invisible one.
|
||||
*/
|
||||
function joinLegs(legs: ValhallaLeg[]): ValhallaLeg {
|
||||
const coordinates: [number, number][] = []
|
||||
for (const leg of legs) {
|
||||
const last = coordinates[coordinates.length - 1]
|
||||
const first = leg.coordinates[0]
|
||||
const continues = last && first && last[0] === first[0] && last[1] === first[1]
|
||||
coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates))
|
||||
}
|
||||
return {
|
||||
coordinates,
|
||||
distance: legs.reduce((sum, l) => sum + l.distance, 0),
|
||||
duration: legs.reduce((sum, l) => sum + l.duration, 0),
|
||||
// One tolled leg makes the drive a tolled drive: `some`, never `every`, or a day
|
||||
// with a single toll booth in it would be labelled toll-free.
|
||||
hasToll: legs.some(l => l.hasToll),
|
||||
hasHighway: legs.some(l => l.hasHighway),
|
||||
hasFerry: legs.some(l => l.hasFerry),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Valhalla answer as a leg, or null when it is not one.
|
||||
*
|
||||
* Split out because it is the whole of the format knowledge and the only part worth
|
||||
* testing without a network: the native shape, not `format=osrm`. That flag exists and
|
||||
* would have kept the response side unchanged, but it drops `has_toll` — the one field
|
||||
* this is all for — and carries every intersection instead, which measured 5.7 times
|
||||
* larger on Paris to Lyon. Translating a smaller answer beats parsing a familiar one.
|
||||
*/
|
||||
/**
|
||||
* The answer read leg by leg, which is what a whole day needs.
|
||||
*
|
||||
* Valhalla gives every leg its own summary, so a chain comes back already divided the
|
||||
* way a road trip day wants it: this stop to the next, with its own distance, its own
|
||||
* drive time, and its own honest `has_toll`. Reading only the trip summary would flatten
|
||||
* a day into one number and lose the per-leg times the schedule is built from.
|
||||
*
|
||||
* The fallback to the trip summary is for a single leg only. Where several legs carry no
|
||||
* summary of their own there is no honest way to divide one total between them, and a
|
||||
* guess here would arrive as a wrong arrival time rather than as a missing one.
|
||||
*/
|
||||
export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
|
||||
const trip = tripOf(data)
|
||||
if (!trip || !Array.isArray(trip.legs) || !trip.legs.length) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
for (const leg of trip.legs) {
|
||||
if (typeof leg.shape !== 'string') return null
|
||||
const summary = leg.summary ?? (trip.legs.length === 1 ? trip.summary : undefined)
|
||||
if (!summary) return null
|
||||
// Precision 6, verified against a known point: decoded as 5 the first vertex of a
|
||||
// Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193.
|
||||
const measure = measured(summary, decodePolyline(leg.shape, 6))
|
||||
if (!measure) return null
|
||||
legs.push(measure)
|
||||
}
|
||||
return { legs, total: joinLegs(legs) }
|
||||
}
|
||||
|
||||
interface ValhallaSummary { [key: string]: number | boolean | undefined }
|
||||
|
||||
function tripOf(data: unknown) {
|
||||
return (data as {
|
||||
trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary }
|
||||
})?.trip
|
||||
}
|
||||
|
||||
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
|
||||
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
|
||||
if (!coordinates.length) return null
|
||||
const length = Number(summary.length)
|
||||
const time = Number(summary.time)
|
||||
if (!Number.isFinite(length) || !Number.isFinite(time)) return null
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
// Asked in kilometres, answered in kilometres, handed on in metres because that is
|
||||
// what every caller already reads off an OSRM route.
|
||||
distance: length * 1000,
|
||||
duration: time,
|
||||
hasToll: summary.has_toll === true,
|
||||
hasHighway: summary.has_highway === true,
|
||||
hasFerry: summary.has_ferry === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route actually avoids what it was asked to avoid.
|
||||
*
|
||||
* The reason this function exists rather than a boolean on the request: `use_tolls: 0`
|
||||
* is a weighting, so between two points with no untolled connection Valhalla answers a
|
||||
* tolled route and reports it honestly. Labelling that "No tolls" would be a lie on
|
||||
* screen, and the flags are in the answer precisely so nobody has to guess.
|
||||
*/
|
||||
export function legAvoids(leg: ValhallaLeg, avoid: AvoidClass): boolean {
|
||||
if (avoid === 'toll') return !leg.hasToll
|
||||
if (avoid === 'motorway') return !leg.hasHighway
|
||||
return !leg.hasFerry
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { convertDistance, formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { formatDurationShort, parseAvoid, serializeAvoid, AVOIDABLE } from './roadtripModel'
|
||||
import { valhallaAvailable } from '../Map/valhallaRoute'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { DistanceUnit, RouteAvoidClass } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
@@ -76,9 +78,39 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One class the drive should leave out, with the switch that asks for it.
|
||||
*
|
||||
* Disabled rather than hidden when there is no engine that can answer: an operator who
|
||||
* pointed the instance at their own OSRM has no second engine, and a switch that flips
|
||||
* and changes nothing is worse than one that says why it cannot.
|
||||
*/
|
||||
function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
|
||||
icon: typeof Coins
|
||||
label: string
|
||||
on: boolean
|
||||
disabled: boolean
|
||||
onToggle: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
{disabled
|
||||
? <span className="text-caption text-content-faint">{'—'}</span>
|
||||
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoadtripLimitsCard({ onSave }: {
|
||||
/** Persists one setting. Absent leaves the dialog read-only. */
|
||||
onSave?: (key: string, value: number) => void
|
||||
/**
|
||||
* Persists one setting. Absent leaves the dialog read-only.
|
||||
*
|
||||
* A string as well as a number since the avoidance is stored as a comma list: one
|
||||
* decision with three parts, which goes to the router as one request either way.
|
||||
*/
|
||||
onSave?: (key: string, value: number | string) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
@@ -89,6 +121,23 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
const rangeKm = settings.roadtrip_range_km
|
||||
|
||||
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
|
||||
// an unknown word here would become a costing option the router does not have.
|
||||
const avoiding = parseAvoid(settings.roadtrip_avoid)
|
||||
// No second engine, no avoidance. An instance pointed at its own OSRM has one, and its
|
||||
// car profile is built without excludable classes on every public host.
|
||||
const canAvoid = valhallaAvailable()
|
||||
const toggleAvoid = (cls: RouteAvoidClass) => {
|
||||
const next = avoiding.includes(cls) ? avoiding.filter(c => c !== cls) : [...avoiding, cls]
|
||||
onSave?.('roadtrip_avoid', serializeAvoid(next))
|
||||
}
|
||||
|
||||
const AVOID_ROWS: { cls: RouteAvoidClass; icon: typeof Coins; label: string }[] = [
|
||||
{ cls: 'toll', icon: Coins, label: t('roadtrip.avoid.toll') },
|
||||
{ cls: 'motorway', icon: Signpost, label: t('roadtrip.avoid.motorway') },
|
||||
{ cls: 'ferry', icon: Ship, label: t('roadtrip.avoid.ferry') },
|
||||
]
|
||||
|
||||
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
|
||||
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
|
||||
// third too early for ever after.
|
||||
@@ -107,6 +156,12 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
|
||||
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
|
||||
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
|
||||
// The avoidance rides here too, and as ONE badge rather than three: it is a single
|
||||
// decision, the three icons already say which parts of it are on, and a fourth,
|
||||
// fifth and sixth badge would wrap the row onto a second line in a narrow rail.
|
||||
avoiding.length && canAvoid
|
||||
? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) }
|
||||
: null,
|
||||
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
|
||||
|
||||
return (
|
||||
@@ -193,6 +248,35 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
rail does not model one. A limit called "travel time per day" over that
|
||||
number would be a lie that looks tidy. */}
|
||||
<p className="text-caption text-content-faint">{t('roadtrip.limit.hint')}</p>
|
||||
|
||||
{/* A heading and a rule, because six rows in one list stop reading as a form
|
||||
and start reading as a wall. The three above are numbers that warn; the
|
||||
three below change the road itself, which is a different kind of setting
|
||||
and worth the separation. */}
|
||||
<div className="mt-1 border-t border-edge-faint pt-4">
|
||||
<p className="mb-3 text-caption font-semibold uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.avoid.section')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{AVOID_ROWS.map(({ cls, icon, label }) => (
|
||||
<AvoidRow
|
||||
key={cls}
|
||||
icon={icon}
|
||||
label={label}
|
||||
on={avoiding.includes(cls)}
|
||||
disabled={!canAvoid}
|
||||
onToggle={() => toggleAvoid(cls)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* "Where possible" is doing real work in the label, so it is spelled out
|
||||
here: the router weights a class down rather than banning it, and on a
|
||||
drive with no way round one the road still uses it. The day says so
|
||||
itself when that happens, which is the only honest way to offer this. */}
|
||||
<p className="mt-3 text-caption text-content-faint">
|
||||
{canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../mobile/components/MDancingTrek'
|
||||
@@ -887,6 +887,21 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
<span className={`${DAY_BADGE} bg-surface-tertiary`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })}
|
||||
</span>
|
||||
{/* The other half of "where possible". The setting is a weighting, so a day
|
||||
with no untolled crossing comes back on the toll road — and the only thing
|
||||
worse than not avoiding it is not avoiding it silently, which reads as the
|
||||
switch being broken. Among the day's facts because that is what it is: a
|
||||
fact about this day's roads, not a warning about the plan. */}
|
||||
{day.avoidMissed?.length ? (
|
||||
<Tooltip label={t('roadtrip.avoid.missedHint')}>
|
||||
<span className={`${DAY_BADGE} gap-1 bg-surface-tertiary text-content-secondary`} style={{ fontSize: FS.label }}>
|
||||
<Ban size={10} className="shrink-0" aria-hidden />
|
||||
{t('roadtrip.avoid.missed', {
|
||||
classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
|
||||
})}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{/* Among the day's facts rather than beside its title, because "which road this
|
||||
day takes" is one of them. Tinted once the day carries vias: the rail draws
|
||||
none of them, so this badge is the only place a drive shaped by hand differs
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
* reason this is not simply the midpoint.
|
||||
*/
|
||||
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry' }
|
||||
|
||||
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
|
||||
function line(n: number, lat: number): [number, number][] {
|
||||
|
||||
@@ -83,7 +83,7 @@ function mostDistinctPoint(
|
||||
*/
|
||||
export function buildAlternativeOverlays(
|
||||
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string },
|
||||
): AlternativeOverlay[] {
|
||||
if (!routes?.length) return []
|
||||
// A single answer is not a choice; drawing it would just double the route already there.
|
||||
@@ -114,7 +114,9 @@ export function buildAlternativeOverlays(
|
||||
? labels.noMotorway
|
||||
: route.avoids === 'toll'
|
||||
? labels.noToll
|
||||
: index === quickest ? labels.fastest : '',
|
||||
: route.avoids === 'ferry'
|
||||
? labels.noFerry
|
||||
: index === quickest ? labels.fastest : '',
|
||||
duration: route.duration,
|
||||
distance: route.distance,
|
||||
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* on its own and reused by the map, the rail and (later) the MCP tool.
|
||||
*/
|
||||
|
||||
import type { RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* The stop kinds that interrupt a drive rather than end it.
|
||||
*
|
||||
@@ -670,3 +672,26 @@ export function reanchorAfterReorder(
|
||||
export function isEmptyReanchoring(r: Reanchoring): boolean {
|
||||
return r.vias.length === 0 && r.remove.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The road classes a stored setting asks to leave out, validated.
|
||||
*
|
||||
* Parsed rather than trusted. A per-user setting has no server-side validation at all —
|
||||
* the write route stores any key with any value and says so — so an unknown word here
|
||||
* would travel straight into a costing option the router does not have. Unknown entries
|
||||
* are dropped, the order is fixed so two equal settings produce one cache key, and
|
||||
* anything that is not a non-empty list reads as "route normally".
|
||||
*/
|
||||
export function parseAvoid(raw: unknown): RouteAvoidClass[] {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return []
|
||||
const asked = new Set(raw.split(',').map(part => part.trim().toLowerCase()))
|
||||
return AVOIDABLE.filter(cls => asked.has(cls))
|
||||
}
|
||||
|
||||
/** Every class that can be avoided, in the order they are offered and stored. */
|
||||
export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry']
|
||||
|
||||
/** The setting value for a set of classes, in the fixed order. */
|
||||
export function serializeAvoid(classes: readonly RouteAvoidClass[]): string {
|
||||
return AVOIDABLE.filter(cls => classes.includes(cls)).join(',')
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import {
|
||||
computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning,
|
||||
} from './roadtripModel'
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid,} from './roadtripModel'
|
||||
import { projectOntoRoute } from './corridor'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
import { spurFor } from './accessSpur'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
@@ -53,6 +52,15 @@ export interface RoadtripDay {
|
||||
* stop. Kept out of `RouteSegment` because that type is read by half the map.
|
||||
*/
|
||||
legVias: RouteVia[][]
|
||||
/**
|
||||
* Classes this day was asked to leave out and could not.
|
||||
*
|
||||
* Empty when the drive got what it asked for, and empty when nothing was asked. The
|
||||
* switch says "where possible", and this is what makes that honest rather than a
|
||||
* hedge: a drive with no untolled crossing comes back on the toll road, and the day
|
||||
* has to be able to say so instead of wearing a label the road disproves.
|
||||
*/
|
||||
avoidMissed?: RouteAvoidClass[]
|
||||
/**
|
||||
* The roads actually driven that day, as [lat, lng] — not the straight lines between
|
||||
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
|
||||
@@ -247,12 +255,25 @@ export function useRoadtripRoutes(
|
||||
* filing it twice would draw the spur twice.
|
||||
*/
|
||||
const [snapByDay, setSnapByDay] = useState<Record<number, Record<string, SnappedWaypoint>>>({})
|
||||
/** Per day, the classes it was asked to avoid and did not get. See RoadtripDay.avoidMissed. */
|
||||
const [missedByDay, setMissedByDay] = useState<Record<number, RouteAvoidClass[]>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
|
||||
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
|
||||
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
|
||||
/**
|
||||
* Road classes to weight away, as the settings row stores them: a comma list.
|
||||
*
|
||||
* Parsed against the known classes rather than trusted, because there is no
|
||||
* server-side validation for a per-user setting — the write route stores any key with
|
||||
* any value — and an unknown word here would become a costing option the router does
|
||||
* not have.
|
||||
*/
|
||||
const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid)
|
||||
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
|
||||
const avoidKey = avoid.join(',')
|
||||
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
|
||||
// same thing, so it is folded here rather than guarded at every reading.
|
||||
const limits = useMemo<DriveLimits>(
|
||||
@@ -327,6 +348,7 @@ export function useRoadtripRoutes(
|
||||
if (!plan.length) {
|
||||
setLegsByDay({})
|
||||
setSnapByDay({})
|
||||
setMissedByDay({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -339,6 +361,7 @@ export function useRoadtripRoutes(
|
||||
|
||||
const collected: Record<number, Record<string, RoutedLeg>> = {}
|
||||
const collectedSnaps: Record<number, Record<string, SnappedWaypoint>> = {}
|
||||
const collectedMisses: Record<number, RouteAvoidClass[]> = {}
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
|
||||
for (const day of plan) {
|
||||
@@ -379,7 +402,7 @@ export function useRoadtripRoutes(
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(
|
||||
waypoints,
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId, avoid },
|
||||
)
|
||||
// Where each stop ended up. stopAt[i] is that stop's waypoint index, so the
|
||||
// vias threaded in between are skipped: a via is a shape handle, not a
|
||||
@@ -456,6 +479,7 @@ export function useRoadtripRoutes(
|
||||
if (controller.signal.aborted) return
|
||||
setLegsByDay({ ...collected })
|
||||
setSnapByDay({ ...collectedSnaps })
|
||||
setMissedByDay({ ...collectedMisses })
|
||||
// Only pace what actually went out. RouteCalculator answers a repeat from its
|
||||
// cache in well under a millisecond, and switching back into road trip mode is
|
||||
// all repeats — waiting a second between those made a warm view feel broken.
|
||||
@@ -468,7 +492,8 @@ export function useRoadtripRoutes(
|
||||
return () => controller.abort()
|
||||
// planKey stands in for `plan`: same geometry, same legs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [planKey, routeProfile, distanceUnit, tripId])
|
||||
// avoidKey, not `avoid`: a fresh array every render would re-route on every render.
|
||||
}, [planKey, routeProfile, distanceUnit, tripId, avoidKey])
|
||||
|
||||
return useMemo(() => {
|
||||
const lines: [number, number][][] = []
|
||||
|
||||
@@ -772,7 +772,7 @@ export function useTripPlanner() {
|
||||
* per-user preferences on the settings table, the same path the map provider and the
|
||||
* distance unit take, and they are read locally the moment they change.
|
||||
*/
|
||||
const saveRoadtripLimit = useCallback(async (key: string, value: number) => {
|
||||
const saveRoadtripLimit = useCallback(async (key: string, value: number | string) => {
|
||||
try {
|
||||
await updateSettings({ [key]: value } as Partial<Settings>)
|
||||
} catch {
|
||||
@@ -1054,6 +1054,7 @@ export function useTripPlanner() {
|
||||
current: t('roadtrip.alt.current'),
|
||||
noMotorway: t('roadtrip.alt.noMotorway'),
|
||||
noToll: t('roadtrip.alt.noToll'),
|
||||
noFerry: t('roadtrip.alt.noFerry'),
|
||||
}),
|
||||
[routeAlternatives.open, t],
|
||||
)
|
||||
|
||||
@@ -61,6 +61,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
map_tile_url: '',
|
||||
// Empty = the public routing hosts TREK ships with.
|
||||
routing_base_url: '',
|
||||
// Empty = the public Valhalla, unless routing_base_url names an own router, in which
|
||||
// case no second engine is asked at all. See valhallaBase().
|
||||
valhalla_base_url: '',
|
||||
dark_mode: false,
|
||||
// Empty = no personal display currency, so Costs falls back to the trip's own.
|
||||
default_currency: '',
|
||||
|
||||
@@ -115,6 +115,12 @@ export interface Settings {
|
||||
* FOSSGIS hosts, which allow about one request a second.
|
||||
*/
|
||||
routing_base_url?: string
|
||||
/**
|
||||
* Base URL of the Valhalla asked for the avoidance questions OSRM cannot answer.
|
||||
* Undefined means the public FOSSGIS instance TREK ships with; empty means an
|
||||
* operator turned the second engine off and "other ways" goes back to OSRM alone.
|
||||
*/
|
||||
valhalla_base_url?: string
|
||||
dark_mode: boolean | string
|
||||
/** Display currency for Costs. Empty/null = follow each trip's own currency. */
|
||||
default_currency: string | null
|
||||
@@ -143,6 +149,16 @@ export interface Settings {
|
||||
roadtrip_leg_minutes?: number
|
||||
roadtrip_day_minutes?: number
|
||||
roadtrip_range_km?: number
|
||||
/**
|
||||
* Road classes the drive should leave out where it can, as a comma list
|
||||
* ("toll,ferry"). Absent means route normally, which is the right meaning for absent:
|
||||
* the settings row does not exist until somebody turns one of them on, and the first
|
||||
* paint of every session reads this before the settings have loaded.
|
||||
*
|
||||
* A list rather than three booleans because it is one decision with three parts, and
|
||||
* because it goes to the router as one request either way.
|
||||
*/
|
||||
roadtrip_avoid?: string
|
||||
/** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */
|
||||
carto_api_key?: string
|
||||
mapbox_access_token?: string
|
||||
@@ -228,8 +244,20 @@ export interface RouteWithLegs {
|
||||
vias?: RouteVia[]
|
||||
/** One entry per REQUESTED waypoint, in request order. Absent on plugin routes. */
|
||||
snapped?: SnappedWaypoint[]
|
||||
/**
|
||||
* What was asked to be left out, and what the road actually left out.
|
||||
*
|
||||
* The two differ, and that is the point. Valhalla weights a class away rather than
|
||||
* banning it, so a drive with no untolled connection comes back on a toll road and
|
||||
* says so. Present only on a route that was asked to avoid something, so `undefined`
|
||||
* means the question was never put rather than "avoided nothing".
|
||||
*/
|
||||
avoidance?: { asked: RouteAvoidClass[]; achieved: RouteAvoidClass[] }
|
||||
}
|
||||
|
||||
/** A road class a route can be asked to leave out. */
|
||||
export type RouteAvoidClass = 'motorway' | 'toll' | 'ferry'
|
||||
|
||||
export interface RouteResult {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
|
||||
@@ -15,4 +15,19 @@ import { http, HttpResponse } from 'msw';
|
||||
export const externalHandlers = [
|
||||
http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json([])),
|
||||
http.get('https://api.frankfurter.dev/v2/currencies', () => HttpResponse.json({})),
|
||||
/**
|
||||
* The public Valhalla, answered as unreachable by default.
|
||||
*
|
||||
* It is a shipped default rather than a configured host, so every alternatives test
|
||||
* reaches it without asking for it, and the warn-then-perform path above sent those
|
||||
* requests to the real FOSSGIS instance — a test asserting a fixed divergence point
|
||||
* got a genuine one off the A2 instead, which fails differently on every OSM update.
|
||||
*
|
||||
* 503 rather than 404 on purpose: the adapter remembers a 404 as "this host is not a
|
||||
* Valhalla" in module state, which would then leak into whichever file ran next. A
|
||||
* 503 is about this request alone, so it lands as the null every caller already
|
||||
* handles and the OSRM path takes over exactly as it did before. A test about the
|
||||
* Valhalla path overrides this with server.use().
|
||||
*/
|
||||
http.post('https://valhalla1.openstreetmap.de/route', () => new HttpResponse(null, { status: 503 })),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user