diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index b800c3026..d89fa5964 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -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 {

{t('settings.routingBaseHint')}

)} + {/* 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 && ( +
+ + ) => 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" + /> +

{t('settings.valhallaBaseHint')}

+
+ )}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {React.createElement(MapView as any, { diff --git a/client/src/components/Map/RouteCalculator.test.ts b/client/src/components/Map/RouteCalculator.test.ts index 058983811..e7348e037 100644 --- a/client/src/components/Map/RouteCalculator.test.ts +++ b/client/src/components/Map/RouteCalculator.test.ts @@ -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) => ({ + 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 } } + // 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 diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index ce243bf40..10bddc871 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -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 { 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:/`): 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 { + 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() * 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 { + // 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 { + 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 { 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 } } diff --git a/client/src/components/Map/valhallaRoute.test.ts b/client/src/components/Map/valhallaRoute.test.ts new file mode 100644 index 000000000..14526e08e --- /dev/null +++ b/client/src/components/Map/valhallaRoute.test.ts @@ -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 = {}) => ({ + 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) => + 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 | null = null + server.use( + http.post(FOSSGIS_VALHALLA, async ({ request }) => { + body = await request.json() as Record + 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[] = [] + server.use( + http.post(FOSSGIS_VALHALLA, async ({ request }) => { + seen.push(await request.json() as Record) + 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() + }) +}) diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts new file mode 100644 index 000000000..4bbdbde7c --- /dev/null +++ b/client/src/components/Map/valhallaRoute.ts @@ -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 = { + motorway: 'use_highways', + toll: 'use_tolls', + ferry: 'use_ferry', +} + +/** The same three classes the rest of the app names; aliased so this file reads on its own. */ +export type AvoidClass = RouteAvoidClass + +/** + * Locations one request may carry. The eleventh answers + * `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400. + */ +const MAX_LOCATIONS = 10 + +/** + * Pause between the pieces of a split day, in milliseconds. + * + * The public instance allows about one request a second per client, and a day long + * enough to be split is exactly the case that would fire two back to back. Matches the + * 1100 ms the road trip rail already leaves between its own days. + */ +const CHUNK_PAUSE_MS = 1100 + +/** + * Extra attempts for one piece of a split day. + * + * One, not a ladder: the common failure here is the public instance's rate limit, which + * a single pause clears, and anything a second attempt cannot fix is not going to be + * fixed by a fourth while somebody waits for their map. + */ +const CHUNK_RETRIES = 1 + +/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */ +export interface ValhallaLeg { + coordinates: [number, number][] + /** Metres. Valhalla is asked in kilometres and converted here. */ + distance: number + /** Seconds. */ + duration: number + /** What the route ACTUALLY has, as opposed to what was asked for. */ + hasToll: boolean + hasHighway: boolean + hasFerry: boolean +} + +/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */ +export interface ValhallaRun { + legs: ValhallaLeg[] + total: ValhallaLeg + /** + * Where each waypoint actually met the road. + * + * Reconstructed from the shape rather than read from a field, because Valhalla echoes + * the coordinates it was GIVEN in `trip.locations` and never says where it bound them. + * The ends of a leg's line are those points: measured against Füssen, its first vertex + * came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same + * request to the digit. Without this the dashed kerb-to-door spur from #2273 would + * quietly disappear from every day routed by this engine. + */ + snapped: SnappedWaypoint[] +} + +/** + * The instance's Valhalla, or null when it should not ask one. + * + * Blank falls back to the public instance, the same reading `routing_base_url` gets — + * with one condition. An operator who filled in their OWN router chose deliberately + * against the public hosts, usually because their instance is not supposed to talk to + * third parties at all, and quietly adding a second public host they never asked for + * would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD + * pipeline answers `exclude` on its own, which is exactly the fallback below. + */ +export function valhallaBase(): string | null { + const settings = useSettingsStore.getState().settings + const configured = settings.valhalla_base_url?.trim() + if (configured) return configured.replace(/\/+$/, '') + return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA +} + +/** True when this engine can be asked at all, so callers can skip building a request. */ +export function valhallaAvailable(): boolean { + return valhallaBase() !== null +} + +/** Hosts that have already proved they are not a Valhalla, by base URL. */ +const notValhalla = new Set() + +/** + * One leg with a road class weighted away, or null when it cannot be had. + * + * Null for every failure, deliberately: this is an offer next to a route that already + * exists, so a host that is down, a rate limit, a point in a field and a genuinely + * connected-only-by-motorway leg all mean the same thing to the caller — there is no + * second way to show. The one failure worth remembering is a host that is not a + * Valhalla at all, because that is true of every leg from now on rather than this one. + */ +export async function valhallaRouteAvoiding( + from: Waypoint, + to: Waypoint, + profile: 'driving' | 'walking' | 'cycling', + avoid: AvoidClass, + signal?: AbortSignal, +): Promise { + const run = await valhallaRun([from, to], profile, [avoid], signal) + return run ? run.total : null +} + +/** + * A whole chain of waypoints, one leg per pair, or null when it cannot be had. + * + * Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled + * into one request precisely so it appears at once rather than trickling in — so a day + * over the cap is cut into runs that overlap by one point and the legs are concatenated. + * That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5] + * came back within two metres and under a second of the same day asked whole, because + * the shared point is routed to and from identically. + * + * The pieces go out one after another with a pause, since a day long enough to need + * splitting is exactly the one that would otherwise fire two requests inside a second + * at a host that allows one. + */ +export async function valhallaRun( + waypoints: Waypoint[], + profile: 'driving' | 'walking' | 'cycling', + avoid: readonly AvoidClass[], + signal?: AbortSignal, +): Promise { + const base = valhallaBase() + if (!base || notValhalla.has(base) || waypoints.length < 2) return null + + const legs: ValhallaLeg[] = [] + const chunks = chunkWaypoints(waypoints) + for (const [index, chunk] of chunks.entries()) { + if (signal?.aborted) return null + if (index > 0) await pause(CHUNK_PAUSE_MS, signal) + // Retried per piece rather than per day. The caller retries the whole task, so a + // rate limit on the last piece of a split day would otherwise throw away the pieces + // that already answered and ask for all of them again — three times over, against a + // host that allows one request a second. + 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 { + 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 | 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 = {} + 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 | 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 +} diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx index f7c84b720..da58cb498 100644 --- a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -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 ( +
+ + {label} + {disabled + ? {'—'} + : } +
+ ) +} + 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. */}

{t('roadtrip.limit.hint')}

+ + {/* 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. */} +
+

+ {t('roadtrip.avoid.section')} +

+
+ {AVOID_ROWS.map(({ cls, icon, label }) => ( + toggleAvoid(cls)} + /> + ))} +
+ {/* "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. */} +

+ {canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')} +

+
) : null} diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 5b8906147..fd44ac820 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -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 {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })} + {/* 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 ? ( + + + + {t('roadtrip.avoid.missed', { + classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '), + })} + + + ) : 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 diff --git a/client/src/components/Roadtrip/alternativeOverlays.test.ts b/client/src/components/Roadtrip/alternativeOverlays.test.ts index 7cc996f28..9aa94da40 100644 --- a/client/src/components/Roadtrip/alternativeOverlays.test.ts +++ b/client/src/components/Roadtrip/alternativeOverlays.test.ts @@ -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][] { diff --git a/client/src/components/Roadtrip/alternativeOverlays.ts b/client/src/components/Roadtrip/alternativeOverlays.ts index ff9e82f0f..df97f689f 100644 --- a/client/src/components/Roadtrip/alternativeOverlays.ts +++ b/client/src/components/Roadtrip/alternativeOverlays.ts @@ -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)), diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index 3b093ef78..753eee654 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -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(',') +} diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 753b58fa2..77728647f 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -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>>({}) + /** Per day, the classes it was asked to avoid and did not get. See RoadtripDay.avoidMissed. */ + const [missedByDay, setMissedByDay] = useState>({}) const [loading, setLoading] = useState(false) const abortRef = useRef(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( @@ -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> = {} const collectedSnaps: Record> = {} + const collectedMisses: Record = {} const tasks: (() => Promise)[] = [] 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][][] = [] diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 9139bf098..b180a6fc2 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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) } 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], ) diff --git a/client/src/store/settingsStore.ts b/client/src/store/settingsStore.ts index b3450c1d7..d9c2f0169 100644 --- a/client/src/store/settingsStore.ts +++ b/client/src/store/settingsStore.ts @@ -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: '', diff --git a/client/src/types.ts b/client/src/types.ts index 72f55b0ef..67a48079c 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -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 diff --git a/client/tests/helpers/msw/handlers/external.ts b/client/tests/helpers/msw/handlers/external.ts index 8cd9c922f..8a2394302 100644 --- a/client/tests/helpers/msw/handlers/external.ts +++ b/client/tests/helpers/msw/handlers/external.ts @@ -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 })), ]; diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index e80f76689..345bdf55e 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -86,12 +86,18 @@ export async function buildApp(): Promise { // config instead of reading process.env itself. const http = app.get>(httpConfig.KEY); // Same pre-init bridge: a self-hosted routing engine has to be named in connect-src, or - // the browser blocks every request to it without an error the app could report. + // the browser blocks every request to it without an error the app could report. Both + // engines go through the same door — the second one answers the avoidance questions + // the first cannot, and is blocked just as silently when the policy leaves it out. const settings = app.get(SettingsService, { strict: false }); - const routingBase = settings?.getAdminUserDefaults()?.routing_base_url; + const defaults = settings?.getAdminUserDefaults(); + const asUrl = (value: unknown) => (typeof value === 'string' ? value : null); applyGlobalMiddleware(instance, { http, - extraConnectSrc: routingCspOrigins([typeof routingBase === 'string' ? routingBase : null]), + extraConnectSrc: routingCspOrigins([ + asUrl(defaults?.routing_base_url), + asUrl(defaults?.valhalla_base_url), + ]), }); // Same pre-init consumption bridge as httpConfig above: the StorageService // instance is resolvable before init, and the handlers only *register* here — diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index eeb805f59..7094342e1 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -245,6 +245,12 @@ export function applyGlobalMiddleware( "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev", "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", + // The second routing engine, shipped as a default the same way the OSRM hosts + // above are. It is asked only when a leg should avoid tolls, motorways or a + // ferry — which the OSRM hosts answer with HTTP 400, because their car profile + // carries no excludable classes. Origin only, no path: unlike OSRM this one is + // a POST to /route and would grow more endpoints if isochrones ever land. + "https://valhalla1.openstreetmap.de", "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com", "https://tiles.openfreemap.org", // A self-hosted routing engine, when the instance has one configured. Without diff --git a/server/src/nest/common/managed.ts b/server/src/nest/common/managed.ts index bfd41c87b..0e77641dc 100644 --- a/server/src/nest/common/managed.ts +++ b/server/src/nest/common/managed.ts @@ -96,6 +96,7 @@ export const MANAGED_LOCKED_SETTING_KEYS = [ 'smtp_skip_tls_verify', 'smtp_user', 'unsplash_api_key', + 'valhalla_base_url', 'webauthn_origins', 'webauthn_rp_id', ] as const; diff --git a/server/src/nest/settings/settings.service.ts b/server/src/nest/settings/settings.service.ts index 53dd6b584..46df58797 100644 --- a/server/src/nest/settings/settings.service.ts +++ b/server/src/nest/settings/settings.service.ts @@ -42,6 +42,18 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [ * once at boot, and a router the policy does not name fails silently. */ 'routing_base_url', + /** + * The second engine, asked only what OSRM cannot answer: drive this leg without the + * tolls, the motorway or the ferry. The shipped OSRM hosts refuse `exclude` outright, + * so on a default install that question has never had an answer. + * + * Same class as the key above and for the same reason: it names an origin the BROWSER + * connects to, so it has to be in the boot-time connect-src, and changing it needs a + * restart. Empty falls back to the public Valhalla, unless routing_base_url names an + * own engine — an operator who chose their own router is not given a public third + * party they never asked for. + */ + 'valhalla_base_url', // CARTO stamps an "API KEY REQUIRED" watermark into keyless tiles (#2054), and // the key is per-instance rather than per-person: defaultable so one admin // value clears the watermark for everybody at once. @@ -100,7 +112,7 @@ const BOOLEAN_KEYS = new Set(['blur_booking_codes', 'mapbox_3d_e * on the next save instead of blocking it. */ export function isAdminOnlyEndpointSetting(key: string, value: unknown): boolean { - if (key === 'llm_base_url' || key === 'routing_base_url') { + if (key === 'llm_base_url' || key === 'routing_base_url' || key === 'valhalla_base_url') { return typeof value === 'string' && value.trim() !== ''; } if (key === 'llm_provider') return value === 'local'; diff --git a/server/tests/e2e/settings.e2e.test.ts b/server/tests/e2e/settings.e2e.test.ts index 85aedd3d5..866b4a8d5 100644 --- a/server/tests/e2e/settings.e2e.test.ts +++ b/server/tests/e2e/settings.e2e.test.ts @@ -175,6 +175,23 @@ describe('Settings e2e (real auth guard + temp SQLite)', () => { expect(countRows()).toBe(0); }); + it('PUT 403 for a non-admin naming the second routing engine', async () => { + // The Valhalla is the same class as the OSRM above and fails the same way: its + // origin has to be in the boot-time connect-src, which is assembled from the + // admin default alone. A personal row would be read by the route calculator and + // then refused by the browser, so "other ways" would quietly stop offering any. + const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)) + .send({ key: 'valhalla_base_url', value: 'https://valhalla.example.org' }); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'Admin access required' }); + expect(countRows()).toBe(0); + }); + + it('PUT lets a non-admin clear the second routing engine', async () => { + expect((await request(server).put('/api/settings').set('Cookie', sessionCookie(1)) + .send({ key: 'valhalla_base_url', value: '' })).status).toBe(200); + }); + it('PUT is unaffected for a non-LLM key with the same value', async () => { const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)) .send({ key: 'start_page', value: 'local' }); diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index ea1e14dea..18abf4d46 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -52,6 +52,24 @@ describe('global CSP: the other shipped raster presets (#2180)', () => { }); }); +describe('global CSP: the second routing engine', () => { + it('allows the public Valhalla, which is a shipped default like the OSRM hosts', async () => { + // It is asked only when a leg should avoid tolls, a motorway or a ferry — the + // question the OSRM hosts answer with HTTP 400, because their car profile carries + // no excludable classes. Left out here it fails the way CSP always fails a fetch: + // silently, with the switch working and no tile of an answer arriving. + expect(await connectSrcSources()).toContain('https://valhalla1.openstreetmap.de'); + }); + + it('names the origin without a path, because it is a POST to /route', async () => { + // The OSRM entries carry `/route/v1/` because a source with a path matches that + // path alone, and OSRM is only ever asked there. Valhalla is a different shape and + // would grow endpoints (isochrones, map matching), so the origin is the right unit. + const sources = await connectSrcSources(); + expect(sources).not.toContain('https://valhalla1.openstreetmap.de/route'); + }); +}); + describe('global CSP: the imagery host (#2307)', () => { it('allows server.arcgisonline.com, which a GL map reaches through fetch', async () => { // The gap hid behind Leaflet for as long as Leaflet was the only renderer to @@ -166,6 +184,17 @@ describe('routingCspOrigins', () => { 'https://osrm.example.org/table/v1/driving', ])).toEqual(['https://osrm.example.org']); }); + + it('CSP-ROUTING-006: both engines get named, because both are reached from the browser', () => { + // bootstrap hands in the OSRM default and the Valhalla default together. An + // instance that configures its own Valhalla and leaves OSRM public still has to + // have that host in the policy, and the other way round. + expect(routingCspOrigins([ + 'https://osrm.example.org/route/v1/driving', + 'https://valhalla.example.org', + ])).toEqual(['https://osrm.example.org', 'https://valhalla.example.org']); + expect(routingCspOrigins([null, 'https://valhalla.example.org'])).toEqual(['https://valhalla.example.org']); + }); }); describe('connect-src with a self-hosted router', () => { diff --git a/server/tests/unit/nest/managed-keys.test.ts b/server/tests/unit/nest/managed-keys.test.ts index a8b2391b8..3ba5fac13 100644 --- a/server/tests/unit/nest/managed-keys.test.ts +++ b/server/tests/unit/nest/managed-keys.test.ts @@ -70,6 +70,7 @@ describe('managed key assignment', () => { 'smtp_skip_tls_verify', 'smtp_user', 'unsplash_api_key', + 'valhalla_base_url', 'webauthn_origins', 'webauthn_rp_id', ]); diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 6fc2908ec..986ea8e64 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'شحن', 'roadtrip.stop.offRoad': '{distance} من الطريق', 'roadtrip.via.plugin': 'توقف في الطريق', - 'roadtrip.limit.title': 'حدود القيادة', + 'roadtrip.limit.title': 'إعدادات القيادة', 'roadtrip.limit.legLabel': 'أطول قيادة متواصلة', 'roadtrip.limit.dayLabel': 'القيادة في اليوم', 'roadtrip.limit.rangeLabel': 'المدى بخزان واحد', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} زيادة', 'roadtrip.limit.range': '{distance} منذ آخر تعبئة', 'roadtrip.limit.none': 'غير محددة', + 'roadtrip.avoid.section': 'التجنب قدر الإمكان', + 'roadtrip.avoid.toll': 'الطرق ذات الرسوم', + 'roadtrip.avoid.motorway': 'الطرق السريعة', + 'roadtrip.avoid.ferry': 'العبّارات', + 'roadtrip.avoid.hint': 'هذا ترجيح وليس منعًا. عندما لا تجد الرحلة طريقًا بديلًا فإنها تستخدمه، ويذكر اليوم ذلك.', + 'roadtrip.avoid.unavailable': 'غير متاح: هذه النسخة تستخدم محركها الخاص، وهو لا يستطيع استبعاد فئة طرق.', + 'roadtrip.avoid.badge': 'تجنُّب {count}', + 'roadtrip.avoid.missed': 'لا مفر من {classes}', + 'roadtrip.avoid.missedHint': 'لا يوجد في هذا اليوم طريق بديل، لذلك تمر الرحلة منه. أما بقية الأيام فتظل تتجنب ما تستطيع.', 'roadtrip.stop.none': 'وجهة', 'roadtrip.stay.mode.pause': 'استراحة', 'roadtrip.stay.mode.night': 'مبيت', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'المغادرة', 'roadtrip.alt.noMotorway': 'بدون طريق سريع', 'roadtrip.alt.noToll': 'بدون رسوم', + 'roadtrip.alt.noFerry': 'بدون عبّارة', 'roadtrip.summary.partial': 'لا يزال يجري حساب بقية الطريق', }; export default roadtrip; diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 82a58094d..287f1fd24 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -25,6 +25,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا', 'settings.routingBase': 'محرك مسارات خاص', 'settings.routingBaseHint': 'خادم OSRM خاص بك. اتركه فارغًا لاستخدام الخوادم العامة التي تسمح بطلب واحد تقريبًا في الثانية — يكفي ليوم واحد، وضيّق لرحلة برية. يسري بعد إعادة تشغيل الخادم.', + 'settings.valhallaBase': 'خادم Valhalla خاص بك', + 'settings.valhallaBaseHint': 'خادم Valhalla خاص بك. يُسأل عندما يجب أن تتجنب المرحلة الرسوم أو الطرق السريعة أو العبّارات — الخوادم العامة لـ OSRM لا تستطيع الإجابة عن ذلك. الفراغ يستخدم Valhalla العام من FOSSGIS، إلا إذا حُدِّد أعلاه محرك توجيه خاص بك، فلا يُسأل أي محرك ثانٍ. يسري بعد إعادة تشغيل الخادم.', 'settings.mapHint': 'قالب URL لبلاطات الخريطة', 'settings.mapProvider': 'مزود الخريطة', 'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 370d387eb..792bfe69b 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Carregamento', 'roadtrip.stop.offRoad': '{distance} da estrada', 'roadtrip.via.plugin': 'Parada no caminho', - 'roadtrip.limit.title': 'Limites de direção', + 'roadtrip.limit.title': 'Configurações de direção', 'roadtrip.limit.legLabel': 'Trecho mais longo seguido', 'roadtrip.limit.dayLabel': 'Condução por dia', 'roadtrip.limit.rangeLabel': 'Autonomia por tanque', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} a mais', 'roadtrip.limit.range': '{distance} desde o último abastecimento', 'roadtrip.limit.none': 'Nenhum definido', + 'roadtrip.avoid.section': 'Evitar quando possível', + 'roadtrip.avoid.toll': 'Pedágios', + 'roadtrip.avoid.motorway': 'Rodovias', + 'roadtrip.avoid.ferry': 'Balsas', + 'roadtrip.avoid.hint': 'Isso é uma penalização, não uma proibição. Quando o trajeto não tem como desviar, ele usa mesmo assim e o dia avisa.', + 'roadtrip.avoid.unavailable': 'Indisponível: esta instância usa o próprio motor, que não consegue deixar de fora uma classe de via.', + 'roadtrip.avoid.badge': '{count} evitados', + 'roadtrip.avoid.missed': '{classes} inevitável', + 'roadtrip.avoid.missedHint': 'Este dia não tem rota que desvie, então o trajeto usa. Os outros dias continuam evitando o que dá.', 'roadtrip.stop.none': 'Um destino', 'roadtrip.stay.mode.pause': 'Pausa', 'roadtrip.stay.mode.night': 'Pernoite', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Partida', 'roadtrip.alt.noMotorway': 'Sem rodovia', 'roadtrip.alt.noToll': 'Sem pedagio', + 'roadtrip.alt.noFerry': 'Sem balsa', 'roadtrip.summary.partial': 'O restante do trajeto ainda está sendo calculado', }; export default roadtrip; diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index ac9c93725..892e35fa5 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)', 'settings.routingBase': 'Servidor de rotas próprio', 'settings.routingBaseHint': 'Uma instância própria do OSRM. Vazio usa os servidores públicos, que permitem cerca de uma requisição por segundo — suficiente para um dia, apertado para uma viagem de carro. Passa a valer após reiniciar o servidor.', + 'settings.valhallaBase': 'Instância Valhalla própria', + 'settings.valhallaBaseHint': 'Uma instância Valhalla própria. Ela é consultada quando um trecho deve evitar pedágios, rodovias ou balsas — os servidores OSRM públicos não conseguem responder isso. Vazio usa a Valhalla pública da FOSSGIS, a não ser que acima esteja indicado um motor de rotas próprio; nesse caso nenhum segundo motor é consultado. Só vale após reiniciar o servidor.', 'settings.mapHint': 'URL do modelo de blocos do mapa', 'settings.mapProvider': 'Provedor de mapa', 'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 324bdcdb7..f4d692377 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Càrrega', 'roadtrip.stop.offRoad': '{distance} des de la carretera', 'roadtrip.via.plugin': 'Parada al camí', - 'roadtrip.limit.title': 'Límits de conducció', + 'roadtrip.limit.title': 'Preferències de conducció', 'roadtrip.limit.legLabel': 'Trajecte més llarg seguit', 'roadtrip.limit.dayLabel': 'Conducció per dia', 'roadtrip.limit.rangeLabel': 'Autonomia per dipòsit', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} de més', 'roadtrip.limit.range': '{distance} des de l’última càrrega', 'roadtrip.limit.none': 'Cap definit', + 'roadtrip.avoid.section': 'Evita quan sigui possible', + 'roadtrip.avoid.toll': 'Peatges', + 'roadtrip.avoid.motorway': 'Autopistes', + 'roadtrip.avoid.ferry': 'Ferris', + 'roadtrip.avoid.hint': 'Es penalitzen, no es prohibeixen. Quan un trajecte no té manera d’evitar-ho, ho fa servir igualment i el dia ho indica.', + 'roadtrip.avoid.unavailable': 'No disponible: aquesta instància fa servir el seu propi motor, que no pot ometre una classe de via.', + 'roadtrip.avoid.badge': '{count} evitats', + 'roadtrip.avoid.missed': '{classes} inevitable', + 'roadtrip.avoid.missedHint': 'Aquest dia no té cap ruta que ho eviti, així que el trajecte ho fa servir. La resta de dies continuen evitant el que poden.', 'roadtrip.stop.none': 'Una destinació', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Sortida', 'roadtrip.alt.noMotorway': 'Sense autopista', 'roadtrip.alt.noToll': 'Sense peatges', + 'roadtrip.alt.noFerry': 'Sense ferri', 'roadtrip.summary.partial': 'Encara s’està calculant la resta del trajecte', }; export default roadtrip; diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 55267c6c0..c415ad51f 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -16,6 +16,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Deixa-ho buit per a OpenStreetMap (per defecte)', 'settings.routingBase': 'Motor de rutes propi', 'settings.routingBaseHint': 'Una instància pròpia d’OSRM. Buit fa servir els servidors públics, que permeten aproximadament una petició per segon: prou per a un dia, just per a un viatge llarg. Té efecte després de reiniciar el servidor.', + 'settings.valhallaBase': 'Instància Valhalla pròpia', + 'settings.valhallaBaseHint': 'Una instància Valhalla pròpia. Es consulta quan una etapa ha d’evitar peatges, autopistes o ferris: els servidors OSRM públics no poden respondre-ho. Buit fa servir el Valhalla públic de FOSSGIS, tret que a dalt s’indiqui un motor de rutes propi, cas en què no es consulta cap segon motor. Té efecte després de reiniciar el servidor.', 'settings.mapHint': "Plantilla d'URL per als mosaics del mapa", 'settings.mapProvider': 'Proveïdor de mapa', 'settings.mapProviderHint': 'Afecta els mapes de Trip Planner i Journey. Atles sempre utilitza Leaflet.', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index cc0645423..5782bfab8 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Nabíjení', 'roadtrip.stop.offRoad': '{distance} od silnice', 'roadtrip.via.plugin': 'Zastávka po cestě', - 'roadtrip.limit.title': 'Limity jízdy', + 'roadtrip.limit.title': 'Nastavení jízdy', 'roadtrip.limit.legLabel': 'Nejdelší jízda v kuse', 'roadtrip.limit.dayLabel': 'Jízda za den', 'roadtrip.limit.rangeLabel': 'Dojezd na jednu nádrž', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'o {time} více', 'roadtrip.limit.range': '{distance} od posledního tankování', 'roadtrip.limit.none': 'Nenastaveno', + 'roadtrip.avoid.section': 'Vyhnout se, pokud to jde', + 'roadtrip.avoid.toll': 'Mýtné silnice', + 'roadtrip.avoid.motorway': 'Dálnice', + 'roadtrip.avoid.ferry': 'Trajekty', + 'roadtrip.avoid.hint': 'Jde o penalizaci, ne o zákaz. Když se jízda nemá jak vyhnout, použije to a den to řekne.', + 'roadtrip.avoid.unavailable': 'Není k dispozici: tato instance jezdí přes vlastní engine, který neumí vynechat třídu silnic.', + 'roadtrip.avoid.badge': '{count} vynecháno', + 'roadtrip.avoid.missed': '{classes} nelze objet', + 'roadtrip.avoid.missedHint': 'Tento den nemá objízdnou trasu, takže tudy jede. Ostatní dny se dál vyhýbají, čemu můžou.', 'roadtrip.stop.none': 'Cíl cesty', 'roadtrip.stay.mode.pause': 'Pauza', 'roadtrip.stay.mode.night': 'Nocleh', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Odjezd', 'roadtrip.alt.noMotorway': 'Bez dalnice', 'roadtrip.alt.noToll': 'Bez mytneho', + 'roadtrip.alt.noFerry': 'Bez trajektu', 'roadtrip.summary.partial': 'Zbytek trasy se ještě počítá', }; export default roadtrip; diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 9dfdb71c1..17e9d0572 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)', 'settings.routingBase': 'Vlastní směrovací server', 'settings.routingBaseHint': 'Vlastní instance OSRM. Prázdné použije veřejné servery s limitem asi jednoho požadavku za sekundu — na den to stačí, na road trip je to málo. Projeví se po restartu serveru.', + 'settings.valhallaBase': 'Vlastní instance Valhalla', + 'settings.valhallaBaseHint': 'Vlastní instance Valhalla. Ptáme se jí, když má úsek objet mýtné, dálnice nebo trajekty — veřejné servery OSRM to neumí zodpovědět. Prázdné použije veřejnou Valhallu od FOSSGIS, ledaže je výše uveden vlastní směrovací engine; pak se žádný druhý engine neptá. Projeví se po restartu serveru.', 'settings.mapHint': 'URL šablony pro mapové dlaždice', 'settings.mapProvider': 'Poskytovatel mapy', 'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 7b248926f..fcf9d6679 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Laden', 'roadtrip.stop.offRoad': '{distance} von der Straße', 'roadtrip.via.plugin': 'Halt unterwegs', - 'roadtrip.limit.title': 'Fahrgrenzen', + 'roadtrip.limit.title': 'Fahreinstellungen', 'roadtrip.limit.legLabel': 'Längste Fahrt am Stück', 'roadtrip.limit.dayLabel': 'Fahrzeit pro Tag', 'roadtrip.limit.rangeLabel': 'Reichweite pro Tankfüllung', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} drüber', 'roadtrip.limit.range': '{distance} seit dem letzten Tanken', 'roadtrip.limit.none': 'Keine gesetzt', + 'roadtrip.avoid.section': 'Möglichst meiden', + 'roadtrip.avoid.toll': 'Mautstraßen', + 'roadtrip.avoid.motorway': 'Autobahnen', + 'roadtrip.avoid.ferry': 'Fähren', + 'roadtrip.avoid.hint': 'Das wird nur schwächer gewichtet, nicht verboten. Wo eine Fahrt keinen Weg drumherum hat, nutzt sie es trotzdem, und der Tag sagt es.', + 'roadtrip.avoid.unavailable': 'Nicht verfügbar: diese Instanz routet über ihre eigene Maschine, die keine Straßenklasse auslassen kann.', + 'roadtrip.avoid.badge': '{count} gemieden', + 'roadtrip.avoid.missed': '{classes} unvermeidbar', + 'roadtrip.avoid.missedHint': 'An diesem Tag führt kein Weg daran vorbei, also fährt die Route darüber. Alle anderen Tage meiden weiterhin, was sie können.', 'roadtrip.stop.none': 'Ein Ziel', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Übernachten', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Abfahrt', 'roadtrip.alt.noMotorway': 'Ohne Autobahn', 'roadtrip.alt.noToll': 'Ohne Maut', + 'roadtrip.alt.noFerry': 'Ohne Fähre', 'roadtrip.summary.partial': 'Der Rest der Strecke wird noch berechnet', }; export default roadtrip; diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 6a5d6ba95..53bf6ba62 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)', 'settings.routingBase': 'Eigene Routing-Instanz', 'settings.routingBaseHint': 'Eine eigene OSRM-Instanz. Leer nutzt die öffentlichen Server, die etwa eine Anfrage pro Sekunde erlauben — für einen Tag genug, für einen Roadtrip knapp. Wirkt erst nach einem Serverneustart.', + 'settings.valhallaBase': 'Eigene Valhalla-Instanz', + 'settings.valhallaBaseHint': 'Eine eigene Valhalla-Instanz. Sie wird gefragt, wenn eine Etappe Maut, Autobahn oder Fähre meiden soll — die öffentlichen OSRM-Server können das nicht. Leer nutzt die öffentliche FOSSGIS-Valhalla, außer oben steht schon eine eigene Routing-Instanz; dann wird gar keine zweite Maschine gefragt. Wirkt erst nach einem Serverneustart.', 'settings.mapHint': 'URL-Template für die Kartenkacheln', 'settings.mapProvider': 'Kartenanbieter', 'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 572426844..f98714b89 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Charging', 'roadtrip.stop.offRoad': '{distance} from the road', 'roadtrip.via.plugin': 'Stop on the way', - 'roadtrip.limit.title': 'Driving limits', + 'roadtrip.limit.title': 'Driving settings', 'roadtrip.limit.legLabel': 'Longest drive at once', 'roadtrip.limit.dayLabel': 'Driving per day', 'roadtrip.limit.rangeLabel': 'Range on one tank', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} over', 'roadtrip.limit.range': '{distance} since the last fill-up', 'roadtrip.limit.none': 'None set', + 'roadtrip.avoid.section': 'Avoid where possible', + 'roadtrip.avoid.toll': 'Toll roads', + 'roadtrip.avoid.motorway': 'Motorways', + 'roadtrip.avoid.ferry': 'Ferries', + 'roadtrip.avoid.hint': 'These are weighted down, not banned. Where a drive has no way around one, it still uses it and the day says so.', + 'roadtrip.avoid.unavailable': 'Not available: this instance routes through its own engine, which cannot leave a road class out.', + 'roadtrip.avoid.badge': '{count} avoided', + 'roadtrip.avoid.missed': '{classes} unavoidable', + 'roadtrip.avoid.missedHint': 'This day has no route around it, so the drive uses it. Every other day still avoids what it can.', 'roadtrip.stop.none': 'A destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Overnight', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Leave', 'roadtrip.alt.noMotorway': 'No motorway', 'roadtrip.alt.noToll': 'No tolls', + 'roadtrip.alt.noFerry': 'No ferry', 'roadtrip.summary.partial': 'Still working out the rest of the drive', }; export default roadtrip; diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 595a57588..b83ce71c4 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -35,6 +35,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)', 'settings.routingBase': 'Own routing engine', 'settings.routingBaseHint': 'An OSRM instance of your own. Empty uses the public servers, which allow about one request a second — enough for a day, tight for a road trip. Takes effect after a server restart.', + 'settings.valhallaBase': 'Own Valhalla instance', + 'settings.valhallaBaseHint': 'A Valhalla of your own. It is asked when a leg should avoid tolls, motorways or ferries — the public OSRM servers cannot answer that. Empty uses the public FOSSGIS Valhalla, unless an own routing engine is named above, in which case no second engine is asked at all. Takes effect after a server restart.', 'settings.mapHint': 'URL template for map tiles', 'settings.mapProvider': 'Map Provider', 'settings.mapProviderHint': 'Affects Trip Planner and Journey maps. Atlas always uses Leaflet.', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index f19d07bef..14f32415b 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Recarga', 'roadtrip.stop.offRoad': '{distance} desde la carretera', 'roadtrip.via.plugin': 'Parada en el camino', - 'roadtrip.limit.title': 'Límites de conducción', + 'roadtrip.limit.title': 'Ajustes de conducción', 'roadtrip.limit.legLabel': 'Trayecto más largo seguido', 'roadtrip.limit.dayLabel': 'Conducción por día', 'roadtrip.limit.rangeLabel': 'Autonomía por depósito', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} de más', 'roadtrip.limit.range': '{distance} desde el último repostaje', 'roadtrip.limit.none': 'Ninguno definido', + 'roadtrip.avoid.section': 'Evitar si es posible', + 'roadtrip.avoid.toll': 'Peajes', + 'roadtrip.avoid.motorway': 'Autopistas', + 'roadtrip.avoid.ferry': 'Ferris', + 'roadtrip.avoid.hint': 'Se penalizan, no se prohíben. Cuando un trayecto no tiene forma de evitarlo, lo usa igual y el día lo indica.', + 'roadtrip.avoid.unavailable': 'No disponible: esta instancia usa su propio motor, que no puede omitir una clase de vía.', + 'roadtrip.avoid.badge': '{count} evitados', + 'roadtrip.avoid.missed': '{classes} inevitable(s)', + 'roadtrip.avoid.missedHint': 'Este día no tiene ninguna ruta que lo evite, así que el trayecto lo usa. Los demás días siguen evitando lo que pueden.', 'roadtrip.stop.none': 'Un destino', 'roadtrip.stay.mode.pause': 'Parada', 'roadtrip.stay.mode.night': 'Pernoctar', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Salida', 'roadtrip.alt.noMotorway': 'Sin autopista', 'roadtrip.alt.noToll': 'Sin peajes', + 'roadtrip.alt.noFerry': 'Sin ferri', 'roadtrip.summary.partial': 'Aún se está calculando el resto del trayecto', }; export default roadtrip; diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index f8e18ba01..bd918be0f 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Déjalo vacío para OpenStreetMap (por defecto)', 'settings.routingBase': 'Motor de rutas propio', 'settings.routingBaseHint': 'Una instancia propia de OSRM. Vacío usa los servidores públicos, que permiten cerca de una petición por segundo: suficiente para un día, justo para una ruta larga. Surte efecto tras reiniciar el servidor.', + 'settings.valhallaBase': 'Instancia de Valhalla propia', + 'settings.valhallaBaseHint': 'Una instancia de Valhalla propia. Se consulta cuando un tramo debe evitar peajes, autopistas o ferris: los servidores OSRM públicos no pueden responder a eso. Vacío usa el Valhalla público de FOSSGIS, salvo que arriba se indique un motor de rutas propio, en cuyo caso no se consulta ningún segundo motor. Surte efecto tras reiniciar el servidor.', 'settings.mapHint': 'Plantilla de URL para los mosaicos del mapa', 'settings.mapProvider': 'Proveedor de mapa', 'settings.mapProviderHint': 'Afecta a los mapas de Trip Planner y Journey. Atlas siempre usa Leaflet.', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index f757d68cf..245059321 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Recharge', 'roadtrip.stop.offRoad': '{distance} de la route', 'roadtrip.via.plugin': 'Arrêt en chemin', - 'roadtrip.limit.title': 'Limites de conduite', + 'roadtrip.limit.title': 'Réglages de conduite', 'roadtrip.limit.legLabel': 'Plus long trajet d’affilée', 'roadtrip.limit.dayLabel': 'Conduite par jour', 'roadtrip.limit.rangeLabel': 'Autonomie par plein', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} de trop', 'roadtrip.limit.range': '{distance} depuis le dernier plein', 'roadtrip.limit.none': 'Aucune définie', + 'roadtrip.avoid.section': 'Éviter si possible', + 'roadtrip.avoid.toll': 'Routes à péage', + 'roadtrip.avoid.motorway': 'Autoroutes', + 'roadtrip.avoid.ferry': 'Ferries', + 'roadtrip.avoid.hint': 'Ce sont des pondérations, pas des interdictions. Quand un trajet n’a aucun moyen de contourner, il l’emprunte quand même et la journée le signale.', + 'roadtrip.avoid.unavailable': 'Indisponible : cette instance utilise son propre moteur, qui ne sait pas écarter une classe de route.', + 'roadtrip.avoid.badge': '{count} évité(s)', + 'roadtrip.avoid.missed': '{classes} inévitable(s)', + 'roadtrip.avoid.missedHint': 'Cette journée n’a aucun itinéraire de contournement, le trajet l’emprunte donc. Les autres journées évitent toujours ce qu’elles peuvent.', 'roadtrip.stop.none': 'Une destination', 'roadtrip.stay.mode.pause': 'Pause', 'roadtrip.stay.mode.night': 'Nuitée', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Depart', 'roadtrip.alt.noMotorway': 'Sans autoroute', 'roadtrip.alt.noToll': 'Sans peage', + 'roadtrip.alt.noFerry': 'Sans ferry', 'roadtrip.summary.partial': 'Le reste du trajet est encore en cours de calcul', }; export default roadtrip; diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 6b92c5af5..f71265138 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Laissez vide pour OpenStreetMap (par défaut)', 'settings.routingBase': 'Moteur de calcul d’itinéraire propre', 'settings.routingBaseHint': 'Votre propre instance OSRM. Vide utilise les serveurs publics, limités à environ une requête par seconde : suffisant pour une journée, juste pour un road trip. Prend effet après un redémarrage du serveur.', + 'settings.valhallaBase': 'Instance Valhalla propre', + 'settings.valhallaBaseHint': 'Votre propre instance Valhalla. Elle est interrogée quand une étape doit éviter les péages, les autoroutes ou les ferries — les serveurs OSRM publics ne savent pas répondre à cela. Vide utilise le Valhalla public de FOSSGIS, sauf si un moteur de calcul d’itinéraire propre est indiqué ci-dessus, auquel cas aucun second moteur n’est interrogé. Prend effet après un redémarrage du serveur.', 'settings.mapHint': "Modèle d'URL pour les tuiles de carte", 'settings.mapProvider': 'Fournisseur de carte', 'settings.mapProviderHint': 'Affecte les cartes Trip Planner et Journey. Atlas utilise toujours Leaflet.', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index ceeb4c90c..369c33372 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Φόρτιση', 'roadtrip.stop.offRoad': '{distance} από τον δρόμο', 'roadtrip.via.plugin': 'Στάση στον δρόμο', - 'roadtrip.limit.title': 'Όρια οδήγησης', + 'roadtrip.limit.title': 'Ρυθμίσεις οδήγησης', 'roadtrip.limit.legLabel': 'Μεγαλύτερη συνεχής οδήγηση', 'roadtrip.limit.dayLabel': 'Οδήγηση ανά ημέρα', 'roadtrip.limit.rangeLabel': 'Αυτονομία με ένα γέμισμα', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} πάνω', 'roadtrip.limit.range': '{distance} από τον τελευταίο ανεφοδιασμό', 'roadtrip.limit.none': 'Δεν έχουν οριστεί', + 'roadtrip.avoid.section': 'Αποφυγή όπου γίνεται', + 'roadtrip.avoid.toll': 'Δρόμοι με διόδια', + 'roadtrip.avoid.motorway': 'Αυτοκινητόδρομοι', + 'roadtrip.avoid.ferry': 'Φέρι μποτ', + 'roadtrip.avoid.hint': 'Πρόκειται για στάθμιση, όχι απαγόρευση. Όταν μια διαδρομή δεν έχει τρόπο να τα αποφύγει, τα χρησιμοποιεί και η ημέρα το δηλώνει.', + 'roadtrip.avoid.unavailable': 'Μη διαθέσιμο: αυτή η εγκατάσταση χρησιμοποιεί δική της μηχανή, που δεν μπορεί να παραλείψει κατηγορία δρόμου.', + 'roadtrip.avoid.badge': '{count} αποφεύγονται', + 'roadtrip.avoid.missed': '{classes} αναπόφευκτα', + 'roadtrip.avoid.missedHint': 'Αυτή η ημέρα δεν έχει διαδρομή που να τα παρακάμπτει, οπότε η οδήγηση τα χρησιμοποιεί. Οι άλλες ημέρες εξακολουθούν να αποφεύγουν ό,τι μπορούν.', 'roadtrip.stop.none': 'Προορισμός', 'roadtrip.stay.mode.pause': 'Στάση', 'roadtrip.stay.mode.night': 'Διανυκτέρευση', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Αναχωρηση', 'roadtrip.alt.noMotorway': 'Χωρις αυτοκινητοδρομο', 'roadtrip.alt.noToll': 'Χωρις διοδια', + 'roadtrip.alt.noFerry': 'Χωρίς φέρι', 'roadtrip.summary.partial': 'Υπολογίζεται ακόμη η υπόλοιπη διαδρομή', }; export default roadtrip; diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 9793fd420..56b156ea7 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Αφήστε κενό για OpenStreetMap (προεπιλογή)', 'settings.routingBase': 'Δική σας μηχανή δρομολόγησης', 'settings.routingBaseHint': 'Δική σας εγκατάσταση OSRM. Κενό σημαίνει τους δημόσιους διακομιστές, που επιτρέπουν περίπου ένα αίτημα ανά δευτερόλεπτο: αρκεί για μία ημέρα, λίγο για ένα οδικό ταξίδι. Ισχύει μετά από επανεκκίνηση του διακομιστή.', + 'settings.valhallaBase': 'Δική σας υπηρεσία Valhalla', + 'settings.valhallaBaseHint': 'Δική σας υπηρεσία Valhalla. Ρωτιέται όταν ένα σκέλος πρέπει να αποφύγει διόδια, αυτοκινητόδρομους ή φέρι — οι δημόσιοι διακομιστές OSRM δεν μπορούν να απαντήσουν σε αυτό. Κενό χρησιμοποιεί το δημόσιο Valhalla του FOSSGIS, εκτός αν παραπάνω δηλώνεται δική σας μηχανή δρομολόγησης, οπότε δεν ρωτιέται καμία δεύτερη μηχανή. Ισχύει μετά από επανεκκίνηση του διακομιστή.', 'settings.mapHint': 'Πρότυπο URL για πλακίδια χάρτη', 'settings.mapProvider': 'Πάροχος Χάρτη', 'settings.mapProviderHint': diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 311c0f6a8..81cc19e3c 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Töltés', 'roadtrip.stop.offRoad': '{distance} az úttól', 'roadtrip.via.plugin': 'Megálló útközben', - 'roadtrip.limit.title': 'Vezetési korlátok', + 'roadtrip.limit.title': 'Vezetési beállítások', 'roadtrip.limit.legLabel': 'Leghosszabb egyhuzamban vezetés', 'roadtrip.limit.dayLabel': 'Vezetés naponta', 'roadtrip.limit.rangeLabel': 'Hatótáv egy tankkal', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} túl', 'roadtrip.limit.range': '{distance} az utolsó tankolás óta', 'roadtrip.limit.none': 'Nincs beállítva', + 'roadtrip.avoid.section': 'Kerülés, ha lehet', + 'roadtrip.avoid.toll': 'Fizetős utak', + 'roadtrip.avoid.motorway': 'Autópályák', + 'roadtrip.avoid.ferry': 'Kompok', + 'roadtrip.avoid.hint': 'Ez súlyozás, nem tiltás. Ha egy útnak nincs kerülője, mégis használja, és a nap jelzi.', + 'roadtrip.avoid.unavailable': 'Nem érhető el: ez a példány saját motort használ, amely nem tud kihagyni útosztályt.', + 'roadtrip.avoid.badge': '{count} kerülve', + 'roadtrip.avoid.missed': '{classes} nem kerülhető', + 'roadtrip.avoid.missedHint': 'Ezen a napon nincs kerülőút, így az útvonal ezt használja. A többi nap továbbra is kerüli, amit tud.', 'roadtrip.stop.none': 'Úti cél', 'roadtrip.stay.mode.pause': 'Szünet', 'roadtrip.stay.mode.night': 'Éjszakázás', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Indulas', 'roadtrip.alt.noMotorway': 'Autopalya nelkul', 'roadtrip.alt.noToll': 'Dijmentes', + 'roadtrip.alt.noFerry': 'Komp nélkül', 'roadtrip.summary.partial': 'A hátralévő útszakasz számítása még folyamatban', }; export default roadtrip; diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 548b066ca..70b7937b0 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Hagyd üresen az OpenStreetMap használatához (alapértelmezett)', 'settings.routingBase': 'Saját útvonaltervező', 'settings.routingBaseHint': 'Saját OSRM-példány. Üresen hagyva a nyilvános kiszolgálókat használja, amelyek másodpercenként körülbelül egy kérést engednek — egy napra elég, egy autós útra kevés. A kiszolgáló újraindítása után lép életbe.', + 'settings.valhallaBase': 'Saját Valhalla-példány', + 'settings.valhallaBaseHint': 'Saját Valhalla-példány. Akkor kérdezzük meg, ha egy szakasznak kerülnie kell a fizetős utakat, az autópályákat vagy a kompokat — a nyilvános OSRM-kiszolgálók erre nem tudnak válaszolni. Üresen a FOSSGIS nyilvános Valhalláját használja, kivéve ha fent saját útvonaltervező szerepel; akkor egyáltalán nem kérdezünk második motort. A szerver újraindítása után lép életbe.', 'settings.mapHint': 'URL sablon a térképcsempékhez', 'settings.mapProvider': 'Térkép szolgáltató', 'settings.mapProviderHint': 'A Trip Planner és Journey térképekre érvényes. Az Atlas mindig Leafletet használ.', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 945da4563..9b36fafab 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Pengisian daya', 'roadtrip.stop.offRoad': '{distance} dari jalan', 'roadtrip.via.plugin': 'Berhenti di jalan', - 'roadtrip.limit.title': 'Batas mengemudi', + 'roadtrip.limit.title': 'Pengaturan berkendara', 'roadtrip.limit.legLabel': 'Perjalanan terlama sekali jalan', 'roadtrip.limit.dayLabel': 'Mengemudi per hari', 'roadtrip.limit.rangeLabel': 'Jarak per tangki', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'lebih {time}', 'roadtrip.limit.range': '{distance} sejak pengisian terakhir', 'roadtrip.limit.none': 'Belum diatur', + 'roadtrip.avoid.section': 'Hindari bila bisa', + 'roadtrip.avoid.toll': 'Jalan tol', + 'roadtrip.avoid.motorway': 'Jalan bebas hambatan', + 'roadtrip.avoid.ferry': 'Feri', + 'roadtrip.avoid.hint': 'Ini pembobotan, bukan larangan. Kalau satu perjalanan tidak punya jalan memutar, ia tetap melewatinya dan hari itu memberi tahu.', + 'roadtrip.avoid.unavailable': 'Tidak tersedia: instans ini memakai mesinnya sendiri, yang tidak bisa mengecualikan kelas jalan.', + 'roadtrip.avoid.badge': '{count} dihindari', + 'roadtrip.avoid.missed': '{classes} tak terhindarkan', + 'roadtrip.avoid.missedHint': 'Hari ini tidak ada rute memutarnya, jadi perjalanan melewatinya. Hari lain tetap menghindari yang bisa dihindari.', 'roadtrip.stop.none': 'Sebuah tujuan', 'roadtrip.stay.mode.pause': 'Istirahat', 'roadtrip.stay.mode.night': 'Menginap', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Berangkat', 'roadtrip.alt.noMotorway': 'Tanpa jalan tol cepat', 'roadtrip.alt.noToll': 'Tanpa tol', + 'roadtrip.alt.noFerry': 'Tanpa feri', 'roadtrip.summary.partial': 'Sisa perjalanan masih dihitung', }; export default roadtrip; diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 8f8d0a126..5d1914075 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Kosongkan untuk OpenStreetMap (default)', 'settings.routingBase': 'Mesin rute sendiri', 'settings.routingBaseHint': 'Instans OSRM milik sendiri. Kosong berarti server publik, yang mengizinkan sekitar satu permintaan per detik — cukup untuk sehari, mepet untuk perjalanan darat. Berlaku setelah server dimulai ulang.', + 'settings.valhallaBase': 'Instans Valhalla sendiri', + 'settings.valhallaBaseHint': 'Instans Valhalla sendiri. Ia ditanya ketika satu ruas harus menghindari tol, jalan bebas hambatan, atau feri — server OSRM publik tidak bisa menjawabnya. Kosong memakai Valhalla publik dari FOSSGIS, kecuali di atas sudah diisi mesin rute sendiri; maka tidak ada mesin kedua yang ditanya. Berlaku setelah server dijalankan ulang.', 'settings.mapHint': 'Template URL untuk tile peta', 'settings.mapProvider': 'Penyedia peta', 'settings.mapProviderHint': 'Berlaku untuk peta Trip Planner dan Journey. Atlas selalu menggunakan Leaflet.', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index c21e82999..9a9127bb4 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Ricarica', 'roadtrip.stop.offRoad': '{distance} dalla strada', 'roadtrip.via.plugin': 'Sosta lungo il percorso', - 'roadtrip.limit.title': 'Limiti di guida', + 'roadtrip.limit.title': 'Impostazioni di guida', 'roadtrip.limit.legLabel': 'Tratto più lungo di fila', 'roadtrip.limit.dayLabel': 'Guida al giorno', 'roadtrip.limit.rangeLabel': 'Autonomia con un pieno', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} in più', 'roadtrip.limit.range': '{distance} dall’ultimo rifornimento', 'roadtrip.limit.none': 'Nessuno impostato', + 'roadtrip.avoid.section': 'Evita se possibile', + 'roadtrip.avoid.toll': 'Strade a pedaggio', + 'roadtrip.avoid.motorway': 'Autostrade', + 'roadtrip.avoid.ferry': 'Traghetti', + 'roadtrip.avoid.hint': 'Vengono penalizzate, non vietate. Quando un percorso non ha alternative, le usa comunque e la giornata lo segnala.', + 'roadtrip.avoid.unavailable': 'Non disponibile: questa istanza usa il proprio motore, che non sa escludere una classe di strada.', + 'roadtrip.avoid.badge': '{count} evitati', + 'roadtrip.avoid.missed': '{classes} inevitabili', + 'roadtrip.avoid.missedHint': 'Questa giornata non ha percorsi alternativi, quindi il viaggio li usa. Le altre giornate continuano a evitare quel che possono.', 'roadtrip.stop.none': 'Una destinazione', 'roadtrip.stay.mode.pause': 'Sosta', 'roadtrip.stay.mode.night': 'Pernottamento', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Partenza', 'roadtrip.alt.noMotorway': 'Senza autostrada', 'roadtrip.alt.noToll': 'Senza pedaggi', + 'roadtrip.alt.noFerry': 'Senza traghetto', 'roadtrip.summary.partial': 'Il resto del percorso è ancora in calcolo', }; export default roadtrip; diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index f98bcc50c..d60e09895 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Lascia vuoto per OpenStreetMap (predefinito)', 'settings.routingBase': 'Motore di percorso proprio', 'settings.routingBaseHint': 'Una tua istanza OSRM. Vuoto usa i server pubblici, che consentono circa una richiesta al secondo: sufficiente per un giorno, poco per un viaggio in auto. Ha effetto dopo il riavvio del server.', + 'settings.valhallaBase': 'Istanza Valhalla propria', + 'settings.valhallaBaseHint': 'Una istanza Valhalla propria. Viene interrogata quando una tappa deve evitare pedaggi, autostrade o traghetti: i server OSRM pubblici non sanno rispondere. Vuoto usa il Valhalla pubblico di FOSSGIS, a meno che sopra non sia indicato un motore di routing proprio, nel qual caso non viene interrogato alcun secondo motore. Ha effetto dopo il riavvio del server.', 'settings.mapHint': 'Modello URL per i tile della mappa', 'settings.mapProvider': 'Provider mappa', 'settings.mapProviderHint': 'Influisce sulle mappe Trip Planner e Journey. Atlas usa sempre Leaflet.', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index d490eacb6..8de6f1eb1 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '充電', 'roadtrip.stop.offRoad': '道路から{distance}', 'roadtrip.via.plugin': '途中の立ち寄り', - 'roadtrip.limit.title': '運転の上限', + 'roadtrip.limit.title': '運転の設定', 'roadtrip.limit.legLabel': '連続運転の上限', 'roadtrip.limit.dayLabel': '1日の運転', 'roadtrip.limit.rangeLabel': '満タンでの航続', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time}超過', 'roadtrip.limit.range': '前回の給油から{distance}', 'roadtrip.limit.none': '未設定', + 'roadtrip.avoid.section': 'できるだけ避ける', + 'roadtrip.avoid.toll': '有料道路', + 'roadtrip.avoid.motorway': '高速道路', + 'roadtrip.avoid.ferry': 'フェリー', + 'roadtrip.avoid.hint': '禁止ではなく、優先度を下げるだけです。迂回できない場合はそのまま通り、その日に表示されます。', + 'roadtrip.avoid.unavailable': '利用できません。このインスタンスは自前のエンジンを使っており、道路種別を除外できません。', + 'roadtrip.avoid.badge': '{count} 件を回避', + 'roadtrip.avoid.missed': '{classes} は回避できず', + 'roadtrip.avoid.missedHint': 'この日は迂回路がないため、そのまま通ります。ほかの日は引き続き避けられるものを避けます。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '休憩', 'roadtrip.stay.mode.night': '宿泊', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': '出発', 'roadtrip.alt.noMotorway': '高速道路なし', 'roadtrip.alt.noToll': '有料道路なし', + 'roadtrip.alt.noFerry': 'フェリーなし', 'roadtrip.summary.partial': '残りの行程を計算しています', }; export default roadtrip; diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index d8d58f8f1..758d9c1f5 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': '空欄の場合は OpenStreetMap(既定)を使用', 'settings.routingBase': '自前のルーティングサーバー', 'settings.routingBaseHint': '自前の OSRM インスタンス。空欄なら公開サーバーを使いますが、毎秒 1 リクエスト程度の制限があり、1 日分には足りてもロードトリップには足りません。サーバー再起動後に反映されます。', + 'settings.valhallaBase': '自前の Valhalla インスタンス', + 'settings.valhallaBaseHint': '自前の Valhalla インスタンス。有料道路・高速道路・フェリーを避けたい区間で問い合わせます。公開の OSRM サーバーはこれに答えられません。空欄なら FOSSGIS の公開 Valhalla を使いますが、上で自前のルーティングサーバーを指定している場合は二つ目のエンジンを一切問い合わせません。サーバー再起動後に反映されます。', 'settings.mapHint': '地図タイルのURLテンプレート', 'settings.mapProvider': '地図プロバイダー', 'settings.mapProviderHint': '旅程プランナーと日記地図に影響します。Atlas は常に Leaflet を使用します。', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 133f792db..d05b81de7 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '충전', 'roadtrip.stop.offRoad': '도로에서 {distance}', 'roadtrip.via.plugin': '경유 정차', - 'roadtrip.limit.title': '운전 한도', + 'roadtrip.limit.title': '주행 설정', 'roadtrip.limit.legLabel': '연속 운전 최대', 'roadtrip.limit.dayLabel': '하루 운전', 'roadtrip.limit.rangeLabel': '1회 주유 주행거리', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} 초과', 'roadtrip.limit.range': '마지막 주유 이후 {distance}', 'roadtrip.limit.none': '설정 안 됨', + 'roadtrip.avoid.section': '가능하면 피하기', + 'roadtrip.avoid.toll': '유료 도로', + 'roadtrip.avoid.motorway': '고속도로', + 'roadtrip.avoid.ferry': '페리', + 'roadtrip.avoid.hint': '금지가 아니라 가중치를 낮추는 것입니다. 우회로가 없으면 그대로 지나가고, 해당 날짜에 그렇게 표시됩니다.', + 'roadtrip.avoid.unavailable': '사용할 수 없습니다. 이 인스턴스는 자체 엔진으로 경로를 계산하며, 도로 종류를 제외하지 못합니다.', + 'roadtrip.avoid.badge': '{count}개 회피', + 'roadtrip.avoid.missed': '{classes} 회피 불가', + 'roadtrip.avoid.missedHint': '이 날은 우회로가 없어 그대로 지나갑니다. 다른 날은 계속 피할 수 있는 것을 피합니다.', 'roadtrip.stop.none': '목적지', 'roadtrip.stay.mode.pause': '휴식', 'roadtrip.stay.mode.night': '숙박', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': '출발', 'roadtrip.alt.noMotorway': '고속도로 제외', 'roadtrip.alt.noToll': '통행료 없음', + 'roadtrip.alt.noFerry': '페리 없음', 'roadtrip.summary.partial': '남은 구간을 계산하고 있습니다', }; export default roadtrip; diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index fed072ccf..3c9e8ca0c 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': '비워두면 OpenStreetMap (기본값) 사용', 'settings.routingBase': '자체 경로 서버', 'settings.routingBaseHint': '자체 OSRM 인스턴스입니다. 비워 두면 초당 약 한 번의 요청만 허용하는 공개 서버를 사용합니다. 하루치는 충분하지만 로드트립에는 빠듯합니다. 서버를 다시 시작해야 적용됩니다.', + 'settings.valhallaBase': '자체 Valhalla 인스턴스', + 'settings.valhallaBaseHint': '자체 Valhalla 인스턴스. 구간에서 통행료, 고속도로, 페리를 피해야 할 때 이 서버에 묻습니다. 공개 OSRM 서버는 이에 답하지 못합니다. 비워 두면 FOSSGIS의 공개 Valhalla를 쓰지만, 위에 자체 라우팅 서버를 적어 두었다면 두 번째 엔진은 아예 묻지 않습니다. 서버를 다시 시작한 뒤에 적용됩니다.', 'settings.mapHint': '지도 타일 URL 템플릿', 'settings.mapProvider': '지도 공급자', 'settings.mapProviderHint': '여행 플래너 및 Journey 지도에 영향을 줍니다. Atlas는 항상 Leaflet을 사용합니다.', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 2f21a2bb5..6219aac38 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Laden', 'roadtrip.stop.offRoad': '{distance} vanaf de weg', 'roadtrip.via.plugin': 'Stop onderweg', - 'roadtrip.limit.title': 'Rijlimieten', + 'roadtrip.limit.title': 'Rij-instellingen', 'roadtrip.limit.legLabel': 'Langste rit achtereen', 'roadtrip.limit.dayLabel': 'Rijden per dag', 'roadtrip.limit.rangeLabel': 'Bereik per tank', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} te veel', 'roadtrip.limit.range': '{distance} sinds de laatste tankbeurt', 'roadtrip.limit.none': 'Geen ingesteld', + 'roadtrip.avoid.section': 'Vermijden waar het kan', + 'roadtrip.avoid.toll': 'Tolwegen', + 'roadtrip.avoid.motorway': 'Snelwegen', + 'roadtrip.avoid.ferry': 'Veerboten', + 'roadtrip.avoid.hint': 'Ze krijgen minder gewicht, ze worden niet verboden. Waar een rit er niet omheen kan, gebruikt hij ze toch en de dag meldt dat.', + 'roadtrip.avoid.unavailable': 'Niet beschikbaar: deze installatie gebruikt haar eigen motor, die geen wegklasse kan weglaten.', + 'roadtrip.avoid.badge': '{count} vermeden', + 'roadtrip.avoid.missed': '{classes} onvermijdelijk', + 'roadtrip.avoid.missedHint': 'Deze dag heeft er geen route omheen, dus de rit gebruikt het. Alle andere dagen vermijden nog steeds wat ze kunnen.', 'roadtrip.stop.none': 'Een bestemming', 'roadtrip.stay.mode.pause': 'Pauze', 'roadtrip.stay.mode.night': 'Overnachten', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Vertrek', 'roadtrip.alt.noMotorway': 'Zonder snelweg', 'roadtrip.alt.noToll': 'Zonder tol', + 'roadtrip.alt.noFerry': 'Zonder veerboot', 'roadtrip.summary.partial': 'De rest van de rit wordt nog berekend', }; export default roadtrip; diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 52a2a4bc5..afe8ab0d5 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Laat leeg voor OpenStreetMap (standaard)', 'settings.routingBase': 'Eigen routeserver', 'settings.routingBaseHint': 'Een eigen OSRM-server. Leeg gebruikt de publieke servers, die ongeveer één verzoek per seconde toestaan — genoeg voor een dag, krap voor een roadtrip. Werkt na een herstart van de server.', + 'settings.valhallaBase': 'Eigen Valhalla-instantie', + 'settings.valhallaBaseHint': 'Een eigen Valhalla-instantie. Die wordt geraadpleegd wanneer een traject tol, snelwegen of veerboten moet vermijden — dat kunnen de publieke OSRM-servers niet beantwoorden. Leeg gebruikt de publieke FOSSGIS-Valhalla, tenzij hierboven een eigen routeringsmotor staat; dan wordt er helemaal geen tweede motor geraadpleegd. Werkt pas na een herstart van de server.', 'settings.mapHint': 'URL-sjabloon voor kaarttegels', 'settings.mapProvider': 'Kaartprovider', 'settings.mapProviderHint': 'Geldt voor Trip Planner en Journey kaarten. Atlas gebruikt altijd Leaflet.', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 1a47cd41e..9bba0abfe 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Ładowanie', 'roadtrip.stop.offRoad': '{distance} od drogi', 'roadtrip.via.plugin': 'Postojój po drodze', - 'roadtrip.limit.title': 'Limity jazdy', + 'roadtrip.limit.title': 'Ustawienia jazdy', 'roadtrip.limit.legLabel': 'Najdłuższa jazda bez przerwy', 'roadtrip.limit.dayLabel': 'Jazda dziennie', 'roadtrip.limit.rangeLabel': 'Zasięg na jednym baku', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} ponad', 'roadtrip.limit.range': '{distance} od ostatniego tankowania', 'roadtrip.limit.none': 'Brak ustawionych', + 'roadtrip.avoid.section': 'Omijaj, jeśli się da', + 'roadtrip.avoid.toll': 'Drogi płatne', + 'roadtrip.avoid.motorway': 'Autostrady', + 'roadtrip.avoid.ferry': 'Promy', + 'roadtrip.avoid.hint': 'To obniżenie wagi, nie zakaz. Gdy przejazd nie ma jak ominąć, i tak z tego korzysta, a dzień to pokazuje.', + 'roadtrip.avoid.unavailable': 'Niedostępne: ta instancja korzysta z własnego silnika, który nie potrafi pominąć klasy drogi.', + 'roadtrip.avoid.badge': '{count} omijane', + 'roadtrip.avoid.missed': '{classes} nie do ominięcia', + 'roadtrip.avoid.missedHint': 'Tego dnia nie ma trasy w objazd, więc przejazd z tego korzysta. Pozostałe dni nadal omijają, co się da.', 'roadtrip.stop.none': 'Cel podróży', 'roadtrip.stay.mode.pause': 'Postój', 'roadtrip.stay.mode.night': 'Nocleg', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Odjazd', 'roadtrip.alt.noMotorway': 'Bez autostrady', 'roadtrip.alt.noToll': 'Bez oplat', + 'roadtrip.alt.noFerry': 'Bez promu', 'roadtrip.summary.partial': 'Reszta trasy jest jeszcze obliczana', }; export default roadtrip; diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 7223883e1..2e65005ad 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Pozostaw puste dla OpenStreetMap (domyślnie)', 'settings.routingBase': 'Własny silnik tras', 'settings.routingBaseHint': 'Własna instancja OSRM. Puste używa serwerów publicznych, które pozwalają na około jedno zapytanie na sekundę — wystarczy na dzień, mało na road trip. Działa po restarcie serwera.', + 'settings.valhallaBase': 'Własna instancja Valhalli', + 'settings.valhallaBaseHint': 'Własna instancja Valhalli. Pytana wtedy, gdy odcinek ma omijać opłaty, autostrady lub promy — publiczne serwery OSRM nie potrafią na to odpowiedzieć. Puste używa publicznej Valhalli FOSSGIS, chyba że powyżej wskazano własny silnik tras; wtedy żaden drugi silnik nie jest pytany. Działa po ponownym uruchomieniu serwera.', 'settings.mapHint': 'Szablon URL dla kafelków mapy', 'settings.mapProvider': 'Dostawca mapy', 'settings.mapProviderHint': 'Dotyczy map Trip Planner i Journey. Atlas zawsze używa Leaflet.', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 488e7827b..194aa6885 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Зарядка', 'roadtrip.stop.offRoad': '{distance} от дороги', 'roadtrip.via.plugin': 'Остановка в пути', - 'roadtrip.limit.title': 'Пределы вождения', + 'roadtrip.limit.title': 'Настройки вождения', 'roadtrip.limit.legLabel': 'Самый долгий перегон', 'roadtrip.limit.dayLabel': 'Вождение за день', 'roadtrip.limit.rangeLabel': 'Запас хода на баке', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'на {time} больше', 'roadtrip.limit.range': '{distance} с последней заправки', 'roadtrip.limit.none': 'Не заданы', + 'roadtrip.avoid.section': 'Избегать по возможности', + 'roadtrip.avoid.toll': 'Платные дороги', + 'roadtrip.avoid.motorway': 'Автомагистрали', + 'roadtrip.avoid.ferry': 'Паромы', + 'roadtrip.avoid.hint': 'Это понижение веса, а не запрет. Если у поездки нет обхода, она всё равно пройдёт там, и день это покажет.', + 'roadtrip.avoid.unavailable': 'Недоступно: этот экземпляр использует собственный движок, который не умеет исключать класс дорог.', + 'roadtrip.avoid.badge': '{count} в обход', + 'roadtrip.avoid.missed': '{classes} не обойти', + 'roadtrip.avoid.missedHint': 'В этот день объезда нет, поэтому поездка идёт через это. Остальные дни по-прежнему обходят, что могут.', 'roadtrip.stop.none': 'Пункт назначения', 'roadtrip.stay.mode.pause': 'Остановка', 'roadtrip.stay.mode.night': 'Ночёвка', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Отправление', 'roadtrip.alt.noMotorway': 'Без автомагистрали', 'roadtrip.alt.noToll': 'Без платных дорог', + 'roadtrip.alt.noFerry': 'Без парома', 'roadtrip.summary.partial': 'Остальная часть маршрута ещё рассчитывается', }; export default roadtrip; diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 2ad5c54b9..70c8c0dcc 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Оставьте пустым для OpenStreetMap (по умолчанию)', 'settings.routingBase': 'Собственный сервер маршрутов', 'settings.routingBaseHint': 'Собственный экземпляр OSRM. Пусто — используются публичные серверы с ограничением около одного запроса в секунду: на день хватает, для автопутешествия мало. Вступает в силу после перезапуска сервера.', + 'settings.valhallaBase': 'Свой сервер Valhalla', + 'settings.valhallaBaseHint': 'Свой сервер Valhalla. К нему обращаются, когда участок должен обойти платные дороги, автомагистрали или паромы — публичные серверы OSRM на это ответить не могут. Пусто использует публичный Valhalla от FOSSGIS, если выше не указан свой маршрутизатор; тогда второй движок не запрашивается вовсе. Действует после перезапуска сервера.', 'settings.mapHint': 'URL-шаблон для тайлов карты', 'settings.mapProvider': 'Провайдер карты', 'settings.mapProviderHint': 'Применяется к Trip Planner и Journey. Atlas всегда использует Leaflet.', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index efc90368f..f466fe0d6 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Laddning', 'roadtrip.stop.offRoad': '{distance} från vägen', 'roadtrip.via.plugin': 'Stopp på vägen', - 'roadtrip.limit.title': 'Körgränser', + 'roadtrip.limit.title': 'Körinställningar', 'roadtrip.limit.legLabel': 'Längsta körning i sträck', 'roadtrip.limit.dayLabel': 'Körning per dag', 'roadtrip.limit.rangeLabel': 'Räckvidd per tank', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} över', 'roadtrip.limit.range': '{distance} sedan senaste tankningen', 'roadtrip.limit.none': 'Inga angivna', + 'roadtrip.avoid.section': 'Undvik om det går', + 'roadtrip.avoid.toll': 'Avgiftsvägar', + 'roadtrip.avoid.motorway': 'Motorvägar', + 'roadtrip.avoid.ferry': 'Färjor', + 'roadtrip.avoid.hint': 'De viktas ned, de förbjuds inte. När en körning inte kan ta sig runt använder den dem ändå, och dagen säger till.', + 'roadtrip.avoid.unavailable': 'Inte tillgängligt: den här installationen använder sin egen motor, som inte kan utesluta en vägklass.', + 'roadtrip.avoid.badge': '{count} undviks', + 'roadtrip.avoid.missed': '{classes} går inte att undvika', + 'roadtrip.avoid.missedHint': 'Den här dagen har ingen väg runt, så körningen använder det. Övriga dagar undviker fortfarande det de kan.', 'roadtrip.stop.none': 'Ett resmål', 'roadtrip.stay.mode.pause': 'Paus', 'roadtrip.stay.mode.night': 'Övernattning', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Avfard', 'roadtrip.alt.noMotorway': 'Utan motorvag', 'roadtrip.alt.noToll': 'Utan vagavgifter', + 'roadtrip.alt.noFerry': 'Utan färja', 'roadtrip.summary.partial': 'Resten av resan beräknas fortfarande', }; export default roadtrip; diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index a75e7e055..0c5e07858 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Lämna fältet tomt för OpenStreetMap (standard)', 'settings.routingBase': 'Egen ruttmotor', 'settings.routingBaseHint': 'En egen OSRM-instans. Tomt använder de publika servrarna, som tillåter ungefär en förfrågan per sekund – nog för en dag, knappt för en bilresa. Träder i kraft efter omstart av servern.', + 'settings.valhallaBase': 'Egen Valhalla-instans', + 'settings.valhallaBaseHint': 'En egen Valhalla-instans. Den frågas när en etapp ska undvika vägavgifter, motorvägar eller färjor — de publika OSRM-servrarna kan inte svara på det. Tomt använder FOSSGIS publika Valhalla, om inte en egen ruttmotor angetts ovan; då frågas ingen andra motor alls. Börjar gälla efter en omstart av servern.', 'settings.mapHint': 'URL-mall för kartrutor', 'settings.mapProvider': 'Kartleverantör', 'settings.mapProviderHint': 'Påverkar resplaneraren och resedagbokens kartor. Atlas använder alltid Leaflet.', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 5fc67b61e..04eecbfd1 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Şarj', 'roadtrip.stop.offRoad': 'yoldan {distance}', 'roadtrip.via.plugin': 'Yol üzerinde mola', - 'roadtrip.limit.title': 'Sürüş sınırları', + 'roadtrip.limit.title': 'Sürüş ayarları', 'roadtrip.limit.legLabel': 'Aralıksız en uzun sürüş', 'roadtrip.limit.dayLabel': 'Günlük sürüş', 'roadtrip.limit.rangeLabel': 'Bir depoyla menzil', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '{time} fazla', 'roadtrip.limit.range': 'son yakıt alımından beri {distance}', 'roadtrip.limit.none': 'Ayarlanmadı', + 'roadtrip.avoid.section': 'Mümkünse kaçın', + 'roadtrip.avoid.toll': 'Paralı yollar', + 'roadtrip.avoid.motorway': 'Otoyollar', + 'roadtrip.avoid.ferry': 'Feribotlar', + 'roadtrip.avoid.hint': 'Bunlar yasaklanmaz, ağırlığı düşürülür. Bir sürüşün etrafından dolaşma imkânı yoksa yine kullanır ve gün bunu söyler.', + 'roadtrip.avoid.unavailable': 'Kullanılamıyor: bu kurulum kendi motorunu kullanıyor ve bir yol sınıfını dışarıda bırakamıyor.', + 'roadtrip.avoid.badge': '{count} kaçınılıyor', + 'roadtrip.avoid.missed': '{classes} kaçınılmaz', + 'roadtrip.avoid.missedHint': 'Bu günün etrafından dolaşan bir rota yok, bu yüzden sürüş oradan geçiyor. Diğer günler kaçınabildiğinden kaçınmayı sürdürüyor.', 'roadtrip.stop.none': 'Bir varış noktası', 'roadtrip.stay.mode.pause': 'Mola', 'roadtrip.stay.mode.night': 'Konaklama', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Kalkis', 'roadtrip.alt.noMotorway': 'Otoyolsuz', 'roadtrip.alt.noToll': 'Ucretsiz', + 'roadtrip.alt.noFerry': 'Feribotsuz', 'roadtrip.summary.partial': 'Yolun geri kalanı hâlâ hesaplanıyor', }; export default roadtrip; diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 3916ef8ae..ad9da7301 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'OpenStreetMap için boş bırakın (varsayılan)', 'settings.routingBase': 'Kendi rota motoru', 'settings.routingBaseHint': 'Kendi OSRM sunucunuz. Boş bırakılırsa saniyede yaklaşık bir istek veren genel sunucular kullanılır: bir gün için yeter, uzun yolculuk için az. Sunucu yeniden başlatıldığında etkin olur.', + 'settings.valhallaBase': 'Kendi Valhalla sunucunuz', + 'settings.valhallaBaseHint': 'Kendi Valhalla sunucunuz. Bir etabın gişeleri, otoyolları veya feribotları es geçmesi gerektiğinde ona sorulur — açık OSRM sunucuları bunu yanıtlayamaz. Boş bırakılırsa FOSSGIS’in açık Valhalla’sı kullanılır; yukarıda kendi rota motorunuz yazılıysa ikinci bir motor hiç sorulmaz. Sunucu yeniden başlatıldıktan sonra geçerli olur.', 'settings.mapHint': 'Harita kutucukları için URL şablonu', 'settings.mapProvider': 'Harita Sağlayıcısı', 'settings.mapProviderHint': 'Seyahat planlayıcı ve Journey haritalarını etkiler. Atlas her zaman Leaflet kullanır.', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 870a9033d..23c3ae7d0 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Зарядка', 'roadtrip.stop.offRoad': '{distance} від дороги', 'roadtrip.via.plugin': 'Зупинка в дорозі', - 'roadtrip.limit.title': 'Межі водіння', + 'roadtrip.limit.title': 'Налаштування водіння', 'roadtrip.limit.legLabel': 'Найдовший перегін', 'roadtrip.limit.dayLabel': 'Водіння за день', 'roadtrip.limit.rangeLabel': 'Запас ходу на баку', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'на {time} більше', 'roadtrip.limit.range': '{distance} від останньої заправки', 'roadtrip.limit.none': 'Не задано', + 'roadtrip.avoid.section': 'Уникати за можливості', + 'roadtrip.avoid.toll': 'Платні дороги', + 'roadtrip.avoid.motorway': 'Автомагістралі', + 'roadtrip.avoid.ferry': 'Пороми', + 'roadtrip.avoid.hint': 'Це зниження ваги, а не заборона. Якщо в поїздки немає обходу, вона все одно пройде там, і день це покаже.', + 'roadtrip.avoid.unavailable': 'Недоступно: цей екземпляр використовує власний рушій, який не вміє виключати клас доріг.', + 'roadtrip.avoid.badge': '{count} в обхід', + 'roadtrip.avoid.missed': '{classes} не обійти', + 'roadtrip.avoid.missedHint': 'Цього дня об’їзду немає, тому поїздка йде через це. Решта днів і далі обходять те, що можуть.', 'roadtrip.stop.none': 'Пункт призначення', 'roadtrip.stay.mode.pause': 'Зупинка', 'roadtrip.stay.mode.night': 'Ночівля', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Відʼїзд', 'roadtrip.alt.noMotorway': 'Без автомагістралі', 'roadtrip.alt.noToll': 'Без платних доріг', + 'roadtrip.alt.noFerry': 'Без порома', 'roadtrip.summary.partial': 'Решта маршруту ще обчислюється', }; export default roadtrip; diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 3e356c4a1..250fde61e 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -27,6 +27,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Залиште порожнім для OpenStreetMap (за замовчуванням)', 'settings.routingBase': 'Власний сервер маршрутів', 'settings.routingBaseHint': 'Власний примірник OSRM. Порожньо — використовуються публічні сервери з обмеженням близько одного запиту на секунду: на день вистачає, для автоподорожі мало. Діє після перезапуску сервера.', + 'settings.valhallaBase': 'Власний сервер Valhalla', + 'settings.valhallaBaseHint': 'Власний сервер Valhalla. До нього звертаються, коли ділянка має обійти платні дороги, автомагістралі або пороми — публічні сервери OSRM на це відповісти не можуть. Порожнє використовує публічний Valhalla від FOSSGIS, якщо вище не вказано власний маршрутизатор; тоді другий рушій не запитують узагалі. Діє після перезапуску сервера.', 'settings.mapHint': 'URL-шаблон для тайлів карти', 'settings.mapProvider': 'Провайдер карти', 'settings.mapProviderHint': 'Застосовується до Trip Planner та Journey. Atlas завжди використовує Leaflet.', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index e0c831235..5723bf4aa 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Sạc điện', 'roadtrip.stop.offRoad': '{distance} từ đường', 'roadtrip.via.plugin': 'Dừng chân trên đường', - 'roadtrip.limit.title': 'Giới hạn lái xe', + 'roadtrip.limit.title': 'Cài đặt lái xe', 'roadtrip.limit.legLabel': 'Chặng lái dài nhất', 'roadtrip.limit.dayLabel': 'Lái xe mỗi ngày', 'roadtrip.limit.rangeLabel': 'Quãng đường mỗi bình', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': 'vượt {time}', 'roadtrip.limit.range': '{distance} kể từ lần đổ nhiên liệu cuối', 'roadtrip.limit.none': 'Chưa đặt', + 'roadtrip.avoid.section': 'Tránh nếu có thể', + 'roadtrip.avoid.toll': 'Đường thu phí', + 'roadtrip.avoid.motorway': 'Đường cao tốc', + 'roadtrip.avoid.ferry': 'Phà', + 'roadtrip.avoid.hint': 'Đây là hạ trọng số, không phải cấm. Khi một chặng không có đường vòng, nó vẫn đi qua và ngày đó sẽ báo.', + 'roadtrip.avoid.unavailable': 'Không khả dụng: bản cài này dùng máy chủ định tuyến riêng, vốn không bỏ qua được một loại đường.', + 'roadtrip.avoid.badge': 'tránh {count}', + 'roadtrip.avoid.missed': '{classes} không tránh được', + 'roadtrip.avoid.missedHint': 'Ngày này không có tuyến đường vòng, nên chặng đi qua đó. Những ngày khác vẫn tránh những gì có thể.', 'roadtrip.stop.none': 'Một điểm đến', 'roadtrip.stay.mode.pause': 'Nghỉ', 'roadtrip.stay.mode.night': 'Qua đêm', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': 'Roi di', 'roadtrip.alt.noMotorway': 'Khong di cao toc', 'roadtrip.alt.noToll': 'Khong thu phi', + 'roadtrip.alt.noFerry': 'Không phà', 'roadtrip.summary.partial': 'Vẫn đang tính phần còn lại của chặng đường', }; export default roadtrip; diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 6bc035f3d..afa04b608 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': 'Để trống cho OpenStreetMap (mặc định)', 'settings.routingBase': 'Máy chủ định tuyến riêng', 'settings.routingBaseHint': 'Máy chủ OSRM của riêng bạn. Để trống sẽ dùng máy chủ công cộng, cho phép khoảng một yêu cầu mỗi giây — đủ cho một ngày, chật cho chuyến đi dài. Có hiệu lực sau khi khởi động lại máy chủ.', + 'settings.valhallaBase': 'Máy chủ Valhalla riêng', + 'settings.valhallaBaseHint': 'Máy chủ Valhalla riêng. Nó được hỏi khi một chặng cần tránh trạm thu phí, đường cao tốc hoặc phà — các máy chủ OSRM công cộng không trả lời được việc đó. Để trống sẽ dùng Valhalla công cộng của FOSSGIS, trừ khi ở trên đã ghi một máy chủ định tuyến riêng; khi đó không hỏi máy chủ thứ hai nào cả. Có hiệu lực sau khi khởi động lại máy chủ.', 'settings.mapHint': 'Mẫu URL cho ô bản đồ', 'settings.mapProvider': 'Nhà cung cấp bản đồ', 'settings.mapProviderHint': diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index c2f3564ab..6f64cdf3a 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '充電', 'roadtrip.stop.offRoad': '距道路 {distance}', 'roadtrip.via.plugin': '途中停靠', - 'roadtrip.limit.title': '駕駛限制', + 'roadtrip.limit.title': '駕駛設定', 'roadtrip.limit.legLabel': '單次最長駕駛', 'roadtrip.limit.dayLabel': '每天駕駛', 'roadtrip.limit.rangeLabel': '單箱續航', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '超出 {time}', 'roadtrip.limit.range': '距上次加油 {distance}', 'roadtrip.limit.none': '未設定', + 'roadtrip.avoid.section': '盡量避開', + 'roadtrip.avoid.toll': '收費公路', + 'roadtrip.avoid.motorway': '高速公路', + 'roadtrip.avoid.ferry': '渡輪', + 'roadtrip.avoid.hint': '這是降低權重,不是禁止。某段路程若無法繞開,仍會走,當天會說明。', + 'roadtrip.avoid.unavailable': '無法使用:此執行個體使用自建引擎,無法排除某類道路。', + 'roadtrip.avoid.badge': '避開 {count} 項', + 'roadtrip.avoid.missed': '無法避開{classes}', + 'roadtrip.avoid.missedHint': '這一天沒有繞行路線,因此仍會經過。其他日期依舊盡量避開。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '過夜', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': '出發', 'roadtrip.alt.noMotorway': '不走高速', 'roadtrip.alt.noToll': '免收費', + 'roadtrip.alt.noFerry': '不搭渡輪', 'roadtrip.summary.partial': '正在計算剩餘路程', }; export default roadtrip; diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index ca9795168..71c4a204b 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': '留空則使用 OpenStreetMap(預設)', 'settings.routingBase': '自建路徑規劃服務', 'settings.routingBaseHint': '你自己的 OSRM 實例。留空則使用公共伺服器,約每秒一個請求 — 單日夠用,長途自駕則吃緊。需重新啟動伺服器後生效。', + 'settings.valhallaBase': '自建 Valhalla 執行個體', + 'settings.valhallaBaseHint': '自建的 Valhalla 執行個體。當某段路程需要避開收費站、高速公路或渡輪時會向它詢問,公開的 OSRM 伺服器無法回答這類問題。留空則使用 FOSSGIS 的公開 Valhalla;若上方已填寫自建路徑規劃服務,則完全不詢問第二個引擎。重新啟動伺服器後生效。', 'settings.mapHint': '地圖瓦片 URL 模板', 'settings.mapProvider': '地圖提供商', 'settings.mapProviderHint': '影響行程規劃和旅程地圖。Atlas 始終使用 Leaflet。', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 8c34fb6cd..160f0db68 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -18,7 +18,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '充电', 'roadtrip.stop.offRoad': '距道路 {distance}', 'roadtrip.via.plugin': '途中停靠', - 'roadtrip.limit.title': '驾驶限制', + 'roadtrip.limit.title': '驾驶设置', 'roadtrip.limit.legLabel': '单次最长驾驶', 'roadtrip.limit.dayLabel': '每天驾驶', 'roadtrip.limit.rangeLabel': '单箱续航', @@ -29,6 +29,15 @@ const roadtrip: TranslationStrings = { 'roadtrip.limit.dayOver': '超出 {time}', 'roadtrip.limit.range': '距上次加油 {distance}', 'roadtrip.limit.none': '未设置', + 'roadtrip.avoid.section': '尽量避开', + 'roadtrip.avoid.toll': '收费公路', + 'roadtrip.avoid.motorway': '高速公路', + 'roadtrip.avoid.ferry': '轮渡', + 'roadtrip.avoid.hint': '这是降低权重,不是禁止。某段路程若无法绕开,仍会走,当天会说明。', + 'roadtrip.avoid.unavailable': '不可用:此实例使用自建引擎,无法排除某类道路。', + 'roadtrip.avoid.badge': '避开 {count} 项', + 'roadtrip.avoid.missed': '无法避开{classes}', + 'roadtrip.avoid.missedHint': '这一天没有绕行路线,因此仍会经过。其他日期依旧尽量避开。', 'roadtrip.stop.none': '目的地', 'roadtrip.stay.mode.pause': '停留', 'roadtrip.stay.mode.night': '过夜', @@ -120,6 +129,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.stay.leave': '出发', 'roadtrip.alt.noMotorway': '不走高速', 'roadtrip.alt.noToll': '免收费', + 'roadtrip.alt.noFerry': '不乘轮渡', 'roadtrip.summary.partial': '正在计算剩余路程', }; export default roadtrip; diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index f1642bd84..4b1093e3b 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -26,6 +26,8 @@ const settings: TranslationStrings = { 'settings.mapDefaultHint': '留空则使用 OpenStreetMap(默认)', 'settings.routingBase': '自建路径规划服务', 'settings.routingBaseHint': '你自己的 OSRM 实例。留空则使用公共服务器,约每秒一个请求 — 单日够用,长途自驾则吃紧。需重启服务器后生效。', + 'settings.valhallaBase': '自建 Valhalla 实例', + 'settings.valhallaBaseHint': '自建的 Valhalla 实例。当某段路程需要避开收费站、高速公路或轮渡时会向它询问,公共 OSRM 服务器无法回答这类问题。留空则使用 FOSSGIS 的公共 Valhalla;若上方已填写自建路径规划服务,则完全不询问第二个引擎。重启服务器后生效。', 'settings.mapHint': '地图瓦片 URL 模板', 'settings.mapProvider': '地图提供商', 'settings.mapProviderHint': '影响行程规划器和旅程地图。足迹始终使用 Leaflet。',