mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(roadtrip): avoid tolls, motorways and ferries, through a second engine
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { convertDistance, formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { formatDurationShort, parseAvoid, serializeAvoid, AVOIDABLE } from './roadtripModel'
|
||||
import { valhallaAvailable } from '../Map/valhallaRoute'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { DistanceUnit, RouteAvoidClass } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
@@ -76,9 +78,39 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One class the drive should leave out, with the switch that asks for it.
|
||||
*
|
||||
* Disabled rather than hidden when there is no engine that can answer: an operator who
|
||||
* pointed the instance at their own OSRM has no second engine, and a switch that flips
|
||||
* and changes nothing is worse than one that says why it cannot.
|
||||
*/
|
||||
function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
|
||||
icon: typeof Coins
|
||||
label: string
|
||||
on: boolean
|
||||
disabled: boolean
|
||||
onToggle: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${disabled ? 'opacity-50' : ''}`}>
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
{disabled
|
||||
? <span className="text-caption text-content-faint">{'—'}</span>
|
||||
: <ToggleSwitch on={on} onToggle={onToggle} label={label} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoadtripLimitsCard({ onSave }: {
|
||||
/** Persists one setting. Absent leaves the dialog read-only. */
|
||||
onSave?: (key: string, value: number) => void
|
||||
/**
|
||||
* Persists one setting. Absent leaves the dialog read-only.
|
||||
*
|
||||
* A string as well as a number since the avoidance is stored as a comma list: one
|
||||
* decision with three parts, which goes to the router as one request either way.
|
||||
*/
|
||||
onSave?: (key: string, value: number | string) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
@@ -89,6 +121,23 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
const rangeKm = settings.roadtrip_range_km
|
||||
|
||||
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
|
||||
// an unknown word here would become a costing option the router does not have.
|
||||
const avoiding = parseAvoid(settings.roadtrip_avoid)
|
||||
// No second engine, no avoidance. An instance pointed at its own OSRM has one, and its
|
||||
// car profile is built without excludable classes on every public host.
|
||||
const canAvoid = valhallaAvailable()
|
||||
const toggleAvoid = (cls: RouteAvoidClass) => {
|
||||
const next = avoiding.includes(cls) ? avoiding.filter(c => c !== cls) : [...avoiding, cls]
|
||||
onSave?.('roadtrip_avoid', serializeAvoid(next))
|
||||
}
|
||||
|
||||
const AVOID_ROWS: { cls: RouteAvoidClass; icon: typeof Coins; label: string }[] = [
|
||||
{ cls: 'toll', icon: Coins, label: t('roadtrip.avoid.toll') },
|
||||
{ cls: 'motorway', icon: Signpost, label: t('roadtrip.avoid.motorway') },
|
||||
{ cls: 'ferry', icon: Ship, label: t('roadtrip.avoid.ferry') },
|
||||
]
|
||||
|
||||
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
|
||||
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
|
||||
// third too early for ever after.
|
||||
@@ -107,6 +156,12 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
|
||||
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
|
||||
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
|
||||
// The avoidance rides here too, and as ONE badge rather than three: it is a single
|
||||
// decision, the three icons already say which parts of it are on, and a fourth,
|
||||
// fifth and sixth badge would wrap the row onto a second line in a narrow rail.
|
||||
avoiding.length && canAvoid
|
||||
? { key: 'avoid', Icon: AVOID_ROWS.find(r => r.cls === avoiding[0])!.icon, text: t('roadtrip.avoid.badge', { count: avoiding.length }) }
|
||||
: null,
|
||||
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
|
||||
|
||||
return (
|
||||
@@ -193,6 +248,35 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
rail does not model one. A limit called "travel time per day" over that
|
||||
number would be a lie that looks tidy. */}
|
||||
<p className="text-caption text-content-faint">{t('roadtrip.limit.hint')}</p>
|
||||
|
||||
{/* A heading and a rule, because six rows in one list stop reading as a form
|
||||
and start reading as a wall. The three above are numbers that warn; the
|
||||
three below change the road itself, which is a different kind of setting
|
||||
and worth the separation. */}
|
||||
<div className="mt-1 border-t border-edge-faint pt-4">
|
||||
<p className="mb-3 text-caption font-semibold uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.avoid.section')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{AVOID_ROWS.map(({ cls, icon, label }) => (
|
||||
<AvoidRow
|
||||
key={cls}
|
||||
icon={icon}
|
||||
label={label}
|
||||
on={avoiding.includes(cls)}
|
||||
disabled={!canAvoid}
|
||||
onToggle={() => toggleAvoid(cls)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* "Where possible" is doing real work in the label, so it is spelled out
|
||||
here: the router weights a class down rather than banning it, and on a
|
||||
drive with no way round one the road still uses it. The day says so
|
||||
itself when that happens, which is the only honest way to offer this. */}
|
||||
<p className="mt-3 text-caption text-content-faint">
|
||||
{canAvoid ? t('roadtrip.avoid.hint') : t('roadtrip.avoid.unavailable')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../mobile/components/MDancingTrek'
|
||||
@@ -887,6 +887,21 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
<span className={`${DAY_BADGE} bg-surface-tertiary`} style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })}
|
||||
</span>
|
||||
{/* The other half of "where possible". The setting is a weighting, so a day
|
||||
with no untolled crossing comes back on the toll road — and the only thing
|
||||
worse than not avoiding it is not avoiding it silently, which reads as the
|
||||
switch being broken. Among the day's facts because that is what it is: a
|
||||
fact about this day's roads, not a warning about the plan. */}
|
||||
{day.avoidMissed?.length ? (
|
||||
<Tooltip label={t('roadtrip.avoid.missedHint')}>
|
||||
<span className={`${DAY_BADGE} gap-1 bg-surface-tertiary text-content-secondary`} style={{ fontSize: FS.label }}>
|
||||
<Ban size={10} className="shrink-0" aria-hidden />
|
||||
{t('roadtrip.avoid.missed', {
|
||||
classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
|
||||
})}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{/* Among the day's facts rather than beside its title, because "which road this
|
||||
day takes" is one of them. Tinted once the day carries vias: the rail draws
|
||||
none of them, so this badge is the only place a drive shaped by hand differs
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
* reason this is not simply the midpoint.
|
||||
*/
|
||||
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry' }
|
||||
|
||||
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
|
||||
function line(n: number, lat: number): [number, number][] {
|
||||
|
||||
@@ -83,7 +83,7 @@ function mostDistinctPoint(
|
||||
*/
|
||||
export function buildAlternativeOverlays(
|
||||
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string; noFerry: string },
|
||||
): AlternativeOverlay[] {
|
||||
if (!routes?.length) return []
|
||||
// A single answer is not a choice; drawing it would just double the route already there.
|
||||
@@ -114,7 +114,9 @@ export function buildAlternativeOverlays(
|
||||
? labels.noMotorway
|
||||
: route.avoids === 'toll'
|
||||
? labels.noToll
|
||||
: index === quickest ? labels.fastest : '',
|
||||
: route.avoids === 'ferry'
|
||||
? labels.noFerry
|
||||
: index === quickest ? labels.fastest : '',
|
||||
duration: route.duration,
|
||||
distance: route.distance,
|
||||
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* on its own and reused by the map, the rail and (later) the MCP tool.
|
||||
*/
|
||||
|
||||
import type { RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* The stop kinds that interrupt a drive rather than end it.
|
||||
*
|
||||
@@ -670,3 +672,26 @@ export function reanchorAfterReorder(
|
||||
export function isEmptyReanchoring(r: Reanchoring): boolean {
|
||||
return r.vias.length === 0 && r.remove.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The road classes a stored setting asks to leave out, validated.
|
||||
*
|
||||
* Parsed rather than trusted. A per-user setting has no server-side validation at all —
|
||||
* the write route stores any key with any value and says so — so an unknown word here
|
||||
* would travel straight into a costing option the router does not have. Unknown entries
|
||||
* are dropped, the order is fixed so two equal settings produce one cache key, and
|
||||
* anything that is not a non-empty list reads as "route normally".
|
||||
*/
|
||||
export function parseAvoid(raw: unknown): RouteAvoidClass[] {
|
||||
if (typeof raw !== 'string' || !raw.trim()) return []
|
||||
const asked = new Set(raw.split(',').map(part => part.trim().toLowerCase()))
|
||||
return AVOIDABLE.filter(cls => asked.has(cls))
|
||||
}
|
||||
|
||||
/** Every class that can be avoided, in the order they are offered and stored. */
|
||||
export const AVOIDABLE: readonly RouteAvoidClass[] = ['toll', 'motorway', 'ferry']
|
||||
|
||||
/** The setting value for a set of classes, in the fixed order. */
|
||||
export function serializeAvoid(classes: readonly RouteAvoidClass[]): string {
|
||||
return AVOIDABLE.filter(cls => classes.includes(cls)).join(',')
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import {
|
||||
computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning,
|
||||
} from './roadtripModel'
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid,} from './roadtripModel'
|
||||
import { projectOntoRoute } from './corridor'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
import { spurFor } from './accessSpur'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
@@ -53,6 +52,15 @@ export interface RoadtripDay {
|
||||
* stop. Kept out of `RouteSegment` because that type is read by half the map.
|
||||
*/
|
||||
legVias: RouteVia[][]
|
||||
/**
|
||||
* Classes this day was asked to leave out and could not.
|
||||
*
|
||||
* Empty when the drive got what it asked for, and empty when nothing was asked. The
|
||||
* switch says "where possible", and this is what makes that honest rather than a
|
||||
* hedge: a drive with no untolled crossing comes back on the toll road, and the day
|
||||
* has to be able to say so instead of wearing a label the road disproves.
|
||||
*/
|
||||
avoidMissed?: RouteAvoidClass[]
|
||||
/**
|
||||
* The roads actually driven that day, as [lat, lng] — not the straight lines between
|
||||
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
|
||||
@@ -247,12 +255,25 @@ export function useRoadtripRoutes(
|
||||
* filing it twice would draw the spur twice.
|
||||
*/
|
||||
const [snapByDay, setSnapByDay] = useState<Record<number, Record<string, SnappedWaypoint>>>({})
|
||||
/** Per day, the classes it was asked to avoid and did not get. See RoadtripDay.avoidMissed. */
|
||||
const [missedByDay, setMissedByDay] = useState<Record<number, RouteAvoidClass[]>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
|
||||
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
|
||||
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
|
||||
/**
|
||||
* Road classes to weight away, as the settings row stores them: a comma list.
|
||||
*
|
||||
* Parsed against the known classes rather than trusted, because there is no
|
||||
* server-side validation for a per-user setting — the write route stores any key with
|
||||
* any value — and an unknown word here would become a costing option the router does
|
||||
* not have.
|
||||
*/
|
||||
const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid)
|
||||
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
|
||||
const avoidKey = avoid.join(',')
|
||||
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
|
||||
// same thing, so it is folded here rather than guarded at every reading.
|
||||
const limits = useMemo<DriveLimits>(
|
||||
@@ -327,6 +348,7 @@ export function useRoadtripRoutes(
|
||||
if (!plan.length) {
|
||||
setLegsByDay({})
|
||||
setSnapByDay({})
|
||||
setMissedByDay({})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -339,6 +361,7 @@ export function useRoadtripRoutes(
|
||||
|
||||
const collected: Record<number, Record<string, RoutedLeg>> = {}
|
||||
const collectedSnaps: Record<number, Record<string, SnappedWaypoint>> = {}
|
||||
const collectedMisses: Record<number, RouteAvoidClass[]> = {}
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
|
||||
for (const day of plan) {
|
||||
@@ -379,7 +402,7 @@ export function useRoadtripRoutes(
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(
|
||||
waypoints,
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId, avoid },
|
||||
)
|
||||
// Where each stop ended up. stopAt[i] is that stop's waypoint index, so the
|
||||
// vias threaded in between are skipped: a via is a shape handle, not a
|
||||
@@ -456,6 +479,7 @@ export function useRoadtripRoutes(
|
||||
if (controller.signal.aborted) return
|
||||
setLegsByDay({ ...collected })
|
||||
setSnapByDay({ ...collectedSnaps })
|
||||
setMissedByDay({ ...collectedMisses })
|
||||
// Only pace what actually went out. RouteCalculator answers a repeat from its
|
||||
// cache in well under a millisecond, and switching back into road trip mode is
|
||||
// all repeats — waiting a second between those made a warm view feel broken.
|
||||
@@ -468,7 +492,8 @@ export function useRoadtripRoutes(
|
||||
return () => controller.abort()
|
||||
// planKey stands in for `plan`: same geometry, same legs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [planKey, routeProfile, distanceUnit, tripId])
|
||||
// avoidKey, not `avoid`: a fresh array every render would re-route on every render.
|
||||
}, [planKey, routeProfile, distanceUnit, tripId, avoidKey])
|
||||
|
||||
return useMemo(() => {
|
||||
const lines: [number, number][][] = []
|
||||
|
||||
Reference in New Issue
Block a user