diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx
index 8b568d328..ddbb320d8 100644
--- a/client/src/components/Map/MapView.tsx
+++ b/client/src/components/Map/MapView.tsx
@@ -635,6 +635,7 @@ export const MapView = memo(function MapView({
onViewportChange,
tripId,
routeVias = [],
+ accessLines = [],
onPoiDropOnRoute,
onRouteClick,
roadtripVias,
@@ -1045,6 +1046,20 @@ export const MapView = memo(function MapView({
/>,
] : [])}
+ {/* The last bit to a place the road does not reach.
+ Dashed and thin, over the route rather than under it, because it is the one
+ piece of the line that is not driving: the router snapped the stop to the
+ nearest road and the drive really ends there. Same blue as the route, so it
+ reads as the end of that route and not as a second one. */}
+ {(accessLines ?? []).map((spur, i) => (
+
+ ))}
+
{/* The offered ways of driving one leg, over the route they replace. Clicking one
takes it, which is the same choice the bar above the map offers. */}
{/* Every option in blue over a white casing, after Apple Maps: they are all real
diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx
index 0617d6d5f..98293c4e7 100644
--- a/client/src/components/Map/MapViewGL.tsx
+++ b/client/src/components/Map/MapViewGL.tsx
@@ -85,6 +85,8 @@ interface RouteSegment {
// mouseleave never fires" case (#1404).
const NO_PLACES: Place[] = []
const NO_ROUTE_VIAS: RouteVia[] = []
+/** Stable empty default: a fresh array each render would refire the spur effect. */
+const NO_ACCESS_LINES: { line: [[number, number], [number, number]]; meters: number }[] = []
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
const NO_DAY_ORDER: Record = {}
const NO_RESERVATIONS: Reservation[] = []
@@ -101,6 +103,8 @@ interface Props {
tripId?: number | string
// Charging stops / rest areas a plugin route places on the drawn day route.
routeVias?: RouteVia[]
+ /** The dashed last bit to a place the road network does not reach. */
+ accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
route?: [number, number][][] | null
routeSegments?: RouteSegment[]
selectedPlaceId?: number | null
@@ -545,6 +549,7 @@ export function MapViewGL({
dayPlaces = NO_PLACES,
tripId,
routeVias = NO_ROUTE_VIAS,
+ accessLines = NO_ACCESS_LINES,
route = null,
routeSegments = NO_ROUTE_SEGMENTS,
selectedPlaceId = null,
@@ -1073,6 +1078,21 @@ export function MapViewGL({
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
+ // The last bit to a place the road does not reach (#1797). Added after the route
+ // so it draws over it: it is the one piece of the line that is not driving, and it
+ // has to be readable exactly where it meets the road it leaves. Same blue, because
+ // it is the end of that route rather than a second one; dashed short and thin,
+ // which is how a map says "on foot from here" without a legend.
+ if (!map.getSource('trip-access')) {
+ map.addSource('trip-access', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
+ map.addLayer({
+ id: 'trip-access-line',
+ type: 'line',
+ source: 'trip-access',
+ paint: { 'line-color': '#0a84ff', 'line-width': 3, 'line-opacity': 0.85, 'line-dasharray': [1, 2.5] },
+ layout: { 'line-cap': 'round' },
+ })
+ }
// gpx geometries source (place.route_geometry)
if (!map.getSource('trip-gpx')) {
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
@@ -1752,6 +1772,22 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features })
}, [route, mapReady])
+ // Update access-spur geojson
+ useEffect(() => {
+ const map = mapRef.current
+ if (!map) return
+ const src = map.getSource('trip-access') as mapboxgl.GeoJSONSource | undefined
+ if (!src) return
+ src.setData({
+ type: 'FeatureCollection',
+ features: (accessLines || []).map(spur => ({
+ type: 'Feature' as const,
+ properties: { meters: Math.round(spur.meters) },
+ geometry: { type: 'LineString' as const, coordinates: spur.line.map(([lat, lng]) => [lng, lat]) },
+ })),
+ })
+ }, [accessLines, mapReady])
+
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
// Update GPX geometries
diff --git a/client/src/components/Map/RouteCalculator.test.ts b/client/src/components/Map/RouteCalculator.test.ts
index 1631ad525..058983811 100644
--- a/client/src/components/Map/RouteCalculator.test.ts
+++ b/client/src/components/Map/RouteCalculator.test.ts
@@ -789,14 +789,65 @@ describe('calculateAlternatives', () => {
return new HttpResponse(null, { status: 400 })
}))
- await calculateAlternatives([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }], 'driving')
+ await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 13 }, 'driving')
const afterFirst = asked.length
- await calculateAlternatives([{ lat: 51, lng: 9 }, { lat: 50, lng: 12 }], 'driving')
+ await calculateAlternatives({ lat: 51, lng: 9 }, { lat: 50, lng: 12 }, 'driving')
expect(afterFirst).toBe(1)
expect(asked).toHaveLength(1)
})
+ 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
+ // route, while the drive really starts somewhere else.
+ server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
+ code: 'Ok',
+ waypoints: [
+ { location: [10.02, 53.51], distance: 812.5 },
+ { location: [13.0, 52.0], distance: 4 },
+ ],
+ routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
+ })))
+
+ const r = await calculateRouteWithLegs([{ lat: 53.5, lng: 10.0 }, { lat: 52, lng: 13 }])
+
+ expect(r.snapped).toEqual([
+ { asked: [53.5, 10.0], at: [53.51, 10.02], meters: 812.5 },
+ { asked: [52, 13], at: [52.0, 13.0], meters: 4 },
+ ])
+ })
+
+ it('FE-COMP-ROUTECALCULATOR-034: a distance the router left out is worked out from the two points', async () => {
+ server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
+ code: 'Ok',
+ waypoints: [{ location: [10.0, 53.0] }, { location: [13.0, 52.0] }],
+ routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
+ })))
+
+ const r = await calculateRouteWithLegs([{ lat: 53.01, lng: 10.0 }, { lat: 52, lng: 13 }])
+
+ // Roughly 1.1 km for a hundredth of a degree of latitude; the point is that it is a
+ // real number rather than the whole answer being discarded.
+ expect(r.snapped?.[0].meters).toBeGreaterThan(1000)
+ expect(r.snapped?.[0].meters).toBeLessThan(1200)
+ })
+
+ it('FE-COMP-ROUTECALCULATOR-035: an answer that does not describe its waypoints changes nothing', async () => {
+ // All-or-nothing: a partial list would have to be indexed by waypoint anyway, and one
+ // bad entry would hang the spur off the wrong stop.
+ server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
+ code: 'Ok',
+ waypoints: [{ location: [10.0, 53.0] }],
+ routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
+ })))
+
+ const r = await calculateRouteWithLegs([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }])
+
+ expect(r.snapped).toBeUndefined()
+ expect(r.coordinates).toHaveLength(2)
+ })
+
it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () =>
HttpResponse.json({ code: 'NoRoute', routes: [] })))
diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts
index 4f35a5c2d..85cbfb540 100644
--- a/client/src/components/Map/RouteCalculator.ts
+++ b/client/src/components/Map/RouteCalculator.ts
@@ -1,6 +1,7 @@
import { useSettingsStore } from '../../store/settingsStore'
import { pluginsApi } from '../../api/client'
-import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
+import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
+import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
@@ -430,7 +431,8 @@ export async function calculateRouteWithLegs(
}
)
- const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
+ const snapped = readSnapped(data, waypoints)
+ const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
routeCache.set(cacheKey, result)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
@@ -439,6 +441,37 @@ export async function calculateRouteWithLegs(
return result
}
+/**
+ * The snap positions out of an OSRM answer, or undefined if it did not describe them.
+ *
+ * OSRM reports this in every response and TREK has thrown it away since the first route
+ * was drawn. `distance` is the straight line from the coordinate we sent to the road it
+ * used; it is recomputed from the two points when the field is missing or nonsense, so a
+ * mirror that trims its answers still produces a usable gap rather than a wrong one.
+ *
+ * All-or-nothing on purpose: a partial list would have to be indexed by waypoint anyway,
+ * and one bad entry would put a spur on the wrong stop.
+ */
+function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] | undefined {
+ const raw = (data as { waypoints?: unknown })?.waypoints
+ if (!Array.isArray(raw) || raw.length !== waypoints.length) return undefined
+ const out: SnappedWaypoint[] = []
+ for (let i = 0; i < raw.length; i++) {
+ const loc = (raw[i] as { location?: unknown })?.location
+ if (!Array.isArray(loc) || loc.length < 2) return undefined
+ const [lng, lat] = loc as [number, number]
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
+ const asked: [number, number] = [waypoints[i].lat, waypoints[i].lng]
+ const at: [number, number] = [lat, lng]
+ const reported = (raw[i] as { distance?: unknown })?.distance
+ const meters = typeof reported === 'number' && Number.isFinite(reported) && reported >= 0
+ ? reported
+ : haversineKm({ lat: asked[0], lng: asked[1] }, { lat, lng }) * 1000
+ out.push({ asked, at, meters })
+ }
+ return out
+}
+
function getDistanceUnit(): DistanceUnit {
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
}
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index 2fd432b1c..a757255ac 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -10,6 +10,7 @@ import { formatDistance } from '../../utils/units'
import { formatDate, formatClockTime } from '../../utils/formatters'
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds'
+import { spurWorthLabelling } from './accessSpur'
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import { FS } from './typeScale'
import type { RouteSegment } from '../../types'
@@ -459,6 +460,7 @@ function Stop({ stop, number, entry, late, selected, continues, onSelect, onMove
onEditStay?: () => void
}): React.ReactElement {
const { t } = useTranslation()
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const lateText = late ? t('roadtrip.warn.late', { minutes: late.minutes ?? 0 }) : null
return (
+ {/* Only when the walk is far enough to change the plan. A dashed line on the
+ map already says there is a gap; the number is for the case where the gap
+ means luggage, a gate or a track a hire car should not be on. */}
+ {spurWorthLabelling(stop.offRoadMeters) ? (
+
+
+ {t('roadtrip.stop.offRoad', { distance: formatDistance((stop.offRoadMeters ?? 0) / 1000, distanceUnit) })}
+
+ ) : null}
{lateText ? (
({
+ asked: [53.5, 10.0],
+ at: [53.51, 10.01],
+ meters,
+})
+
+describe('access spur', () => {
+ it('FE-ACCESSSPUR-001: a gap worth showing becomes the line from the place to the road', () => {
+ const s = snap(400)
+ expect(spurFor(s)).toEqual([s.asked, s.at])
+ })
+
+ it('FE-ACCESSSPUR-002: map precision is not a gap', () => {
+ // A place pinned off an aerial photo lands a few metres from the centreline it
+ // belongs to all the time. Drawing those would put a dash on nearly every stop.
+ expect(spurFor(snap(0))).toBeNull()
+ expect(spurFor(snap(ACCESS_SPUR_MIN_M - 1))).toBeNull()
+ })
+
+ it('FE-ACCESSSPUR-003: the threshold itself counts as a gap', () => {
+ expect(spurFor(snap(ACCESS_SPUR_MIN_M))).not.toBeNull()
+ })
+
+ it('FE-ACCESSSPUR-004: nothing to draw when the router said nothing', () => {
+ expect(spurFor(undefined)).toBeNull()
+ expect(spurFor(null)).toBeNull()
+ })
+
+ it('FE-ACCESSSPUR-005: a NaN distance is not a gap either', () => {
+ // Recomputed distances can come out NaN from a malformed coordinate, and NaN >= n is
+ // false — asserted rather than assumed, because the guard reads as a plain comparison.
+ expect(spurFor(snap(Number.NaN))).toBeNull()
+ })
+
+ it('FE-ACCESSSPUR-006: only a walk long enough to change the plan gets a number', () => {
+ expect(spurWorthLabelling(ACCESS_LABEL_MIN_M)).toBe(true)
+ expect(spurWorthLabelling(ACCESS_LABEL_MIN_M - 1)).toBe(false)
+ expect(spurWorthLabelling(null)).toBe(false)
+ expect(spurWorthLabelling(undefined)).toBe(false)
+ })
+
+ it('FE-ACCESSSPUR-007: the line is drawn but left unlabelled in between', () => {
+ // The band where the map says "there is a gap" and the rail stays quiet.
+ const between = (ACCESS_SPUR_MIN_M + ACCESS_LABEL_MIN_M) / 2
+ expect(spurFor(snap(between))).not.toBeNull()
+ expect(spurWorthLabelling(between)).toBe(false)
+ })
+})
diff --git a/client/src/components/Roadtrip/accessSpur.ts b/client/src/components/Roadtrip/accessSpur.ts
new file mode 100644
index 000000000..dbb87bb45
--- /dev/null
+++ b/client/src/components/Roadtrip/accessSpur.ts
@@ -0,0 +1,47 @@
+import type { SnappedWaypoint } from '../../types'
+
+/**
+ * The gap between a place and the road the drive actually reaches it from.
+ *
+ * A router snaps every waypoint to the nearest road before it starts, and OSRM does it
+ * with no distance limit. A place set back from the road is therefore driven to from
+ * somewhere else, and the line drawn for that drive begins at the somewhere else without
+ * saying so — which is what a viewpoint above a valley, a farmhouse down a track or a
+ * marina at the end of a pier all look like today: a route that stops short for no
+ * visible reason, or worse, one that appears to cut across open ground.
+ *
+ * Drawn as a dashed spur rather than hidden or silently absorbed, which is the same
+ * answer Google gives: the drive ends at the road, the rest is not driving, and the
+ * picture should say which is which.
+ */
+
+/**
+ * Below this, the gap is map precision rather than missing road.
+ *
+ * Thirty metres is roughly the width of a dual carriageway plus its verge, and a place
+ * pinned from an aerial photo lands that far from the centreline it belongs to all the
+ * time. Drawing those would put a dash on almost every stop and teach people to ignore
+ * the mark entirely.
+ */
+export const ACCESS_SPUR_MIN_M = 30
+
+/**
+ * Above this, the spur is worth a number as well as a line.
+ *
+ * A quarter of a kilometre is where "the car park is over there" turns into something
+ * that changes the plan: a walk with luggage, a locked gate, a track a hire car should
+ * not be on. Below it the dash alone says enough.
+ */
+export const ACCESS_LABEL_MIN_M = 250
+
+/** The two-point line from a place to the road it was routed from, or null if there is none worth drawing. */
+export function spurFor(snapped: SnappedWaypoint | undefined | null): [[number, number], [number, number]] | null {
+ if (!snapped) return null
+ if (!(snapped.meters >= ACCESS_SPUR_MIN_M)) return null
+ return [snapped.asked, snapped.at]
+}
+
+/** Whether this gap is far enough that the rail should print the distance beside it. */
+export function spurWorthLabelling(meters: number | null | undefined): boolean {
+ return typeof meters === 'number' && meters >= ACCESS_LABEL_MIN_M
+}
diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts
index bd71693f8..7bc345c06 100644
--- a/client/src/components/Roadtrip/useRoadtripRoutes.ts
+++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts
@@ -3,7 +3,8 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula
import { resolveLegMode } from '../Planner/legMode'
import { computeSchedule, isServiceStopType, splitIntoRuns, type Schedule } from './roadtripModel'
import { useSettingsStore } from '../../store/settingsStore'
-import type { Assignment, AssignmentsMap, Day, RouteSegment } from '../../types'
+import type { Assignment, AssignmentsMap, Day, RouteSegment, SnappedWaypoint } from '../../types'
+import { spurFor } from './accessSpur'
import type { RoadtripVia } from '@trek/shared'
/** One stop on a day's drive — an assignment whose place actually has coordinates. */
@@ -22,6 +23,12 @@ export interface RoadtripStop {
incomingLegMode: string | null
/** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */
stopType: string | null
+ /**
+ * How far the router had to go to find a road, in metres, once this stop has routed.
+ *
+ * null means either not routed yet or close enough that nobody would call it a gap.
+ */
+ offRoadMeters?: number | null
}
export interface RoadtripDay {
@@ -54,6 +61,14 @@ export interface RoadtripDay {
dayWarning?: DriveWarning | null
}
+/** A place, the road it is driven to from, and how far apart the two are. */
+export interface AccessSpur {
+ line: [[number, number], [number, number]]
+ meters: number
+ /** So the rail and the map can point at the same one. */
+ stopKey: string
+}
+
/** A finding about the drive rather than about a stop. */
export interface DriveWarning {
/** Which leg it belongs to; -1 means the day as a whole. */
@@ -93,7 +108,7 @@ export interface RoadtripRoutes {
* off the road reads as "the drive ends here and the rest is not driving" instead of
* looking like a route that cuts across open country.
*/
- accessLines: { line: [[number, number], [number, number]]; meters: number }[]
+ accessLines: AccessSpur[]
/** The routed legs themselves, so the map can label each connector. */
segments: RouteSegment[]
totalDistance: number
@@ -231,6 +246,14 @@ export function useRoadtripRoutes(
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [legsByDay, setLegsByDay] = useState>>({})
+ /**
+ * Where the router put each stop, by day and stop key.
+ *
+ * Kept beside the legs rather than inside them because it belongs to a stop and a leg
+ * has two of them: the arrival end of one leg is the departure end of the next, and
+ * filing it twice would draw the spur twice.
+ */
+ const [snapByDay, setSnapByDay] = useState>>({})
const [loading, setLoading] = useState(false)
const abortRef = useRef(null)
@@ -295,6 +318,7 @@ export function useRoadtripRoutes(
abortRef.current?.abort()
if (!plan.length) {
setLegsByDay({})
+ setSnapByDay({})
setLoading(false)
return
}
@@ -306,6 +330,7 @@ export function useRoadtripRoutes(
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
const collected: Record> = {}
+ const collectedSnaps: Record> = {}
const tasks: (() => Promise)[] = []
for (const day of plan) {
@@ -348,6 +373,16 @@ export function useRoadtripRoutes(
waypoints,
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId },
)
+ // 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
+ // destination, and a dashed spur hanging off one reads as a fault.
+ if (r.snapped) {
+ const daySnaps = (collectedSnaps[day.dayId] ??= {})
+ run.forEach((stop, i) => {
+ const s = r.snapped?.[stopAt[i]]
+ if (s) daySnaps[stopKey(stop)] = s
+ })
+ }
// Fold the router's per-waypoint legs back onto the stop pairs: a stop pair
// with a via between it comes back as two legs, and the rail shows one.
for (let i = 0; i < run.length - 1; i++) {
@@ -390,6 +425,7 @@ export function useRoadtripRoutes(
await tasks[i]()
if (controller.signal.aborted) return
setLegsByDay({ ...collected })
+ setSnapByDay({ ...collectedSnaps })
// 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.
@@ -415,6 +451,7 @@ export function useRoadtripRoutes(
const out: RoadtripDay[] = []
for (const day of plan) {
const dayLegs = legsByDay[day.dayId] ?? {}
+ const daySnaps = snapByDay[day.dayId] ?? {}
const routed = day.stops.slice(0, -1).map((s, i) => dayLegs[legKey(s, day.stops[i + 1])])
for (const leg of routed) {
if (!leg) continue
@@ -431,7 +468,13 @@ export function useRoadtripRoutes(
day.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
legs.map(l => l?.duration),
)
- out.push({ ...day, legs, schedule, geometry, distance, duration })
+ const stops = day.stops.map(s => {
+ const snap = daySnaps[stopKey(s)]
+ const line = spurFor(snap)
+ if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) })
+ return { ...s, offRoadMeters: line ? snap.meters : null }
+ })
+ out.push({ ...day, stops, legs, schedule, geometry, distance, duration })
}
return {
days: out,
@@ -446,5 +489,5 @@ export function useRoadtripRoutes(
quietDays,
loading,
}
- }, [plan, legsByDay, loading, quietDays])
+ }, [plan, legsByDay, snapByDay, loading, quietDays])
}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 2f80dc6cb..c1e09b13d 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -391,6 +391,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
dayPlaces={dayPlaces}
route={roadtripActive ? roadtripRoutes.lines : route}
routeVias={roadtripActive ? [] : routeVias}
+ accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
showTransitRoutes={transitRoutesShown}
// The route toggle belongs to one day, so the map needs that day to
// know which automated transports may ride it (#2019).
diff --git a/client/src/types.ts b/client/src/types.ts
index c590994e3..e1f0b9b44 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -188,6 +188,23 @@ export interface RouteVia {
dwellSeconds?: number
}
+/**
+ * Where the router put a waypoint we asked about, and how far that is from where we asked.
+ *
+ * Every routing engine snaps a coordinate to the nearest road before it starts, and OSRM
+ * does it with no distance limit at all. A place set back from the road — a viewpoint, a
+ * farmhouse, a marina — is therefore driven to from somewhere else entirely, and the drawn
+ * line starts at that somewhere else without saying so.
+ */
+export interface SnappedWaypoint {
+ /** The coordinate that was asked for, unchanged. */
+ asked: [number, number]
+ /** The point on the road network the router actually used. */
+ at: [number, number]
+ /** Straight-line metres between the two. */
+ meters: number
+}
+
export interface RouteWithLegs {
coordinates: [number, number][]
distance: number
@@ -195,6 +212,8 @@ export interface RouteWithLegs {
legs: RouteSegment[]
/** Present on plugin-provided routes only. */
vias?: RouteVia[]
+ /** One entry per REQUESTED waypoint, in request order. Absent on plugin routes. */
+ snapped?: SnappedWaypoint[]
}
export interface RouteResult {
diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts
index fa632ff2f..1bc3f2080 100644
--- a/shared/src/i18n/ar/roadtrip.ts
+++ b/shared/src/i18n/ar/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} على الطريق',
'roadtrip.poi.fuel': 'وقود',
'roadtrip.poi.charging': 'شحن',
+ 'roadtrip.stop.offRoad': '{distance} من الطريق',
'roadtrip.poi.rest': 'استراحة',
'roadtrip.poi.campsite': 'مخيم',
'roadtrip.poi.food': 'طعام',
diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts
index 6fe5cbca1..328cc939f 100644
--- a/shared/src/i18n/br/roadtrip.ts
+++ b/shared/src/i18n/br/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} no caminho',
'roadtrip.poi.fuel': 'Combustível',
'roadtrip.poi.charging': 'Carregamento',
+ 'roadtrip.stop.offRoad': '{distance} da estrada',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts
index 672b3b73a..7f815fcd5 100644
--- a/shared/src/i18n/ca/roadtrip.ts
+++ b/shared/src/i18n/ca/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} pel camí',
'roadtrip.poi.fuel': 'Combustible',
'roadtrip.poi.charging': 'Càrrega',
+ 'roadtrip.stop.offRoad': '{distance} des de la carretera',
'roadtrip.poi.rest': 'Àrea de descans',
'roadtrip.poi.campsite': 'Càmping',
'roadtrip.poi.food': 'Menjar',
diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts
index 683954adf..24d5a2fcd 100644
--- a/shared/src/i18n/cs/roadtrip.ts
+++ b/shared/src/i18n/cs/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} po cestě',
'roadtrip.poi.fuel': 'Palivo',
'roadtrip.poi.charging': 'Nabíjení',
+ 'roadtrip.stop.offRoad': '{distance} od silnice',
'roadtrip.poi.rest': 'Odpočívadlo',
'roadtrip.poi.campsite': 'Kemp',
'roadtrip.poi.food': 'Jídlo',
diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts
index 478369da7..0d73fd3ac 100644
--- a/shared/src/i18n/de/roadtrip.ts
+++ b/shared/src/i18n/de/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} unterwegs',
'roadtrip.poi.fuel': 'Tanken',
'roadtrip.poi.charging': 'Laden',
+ 'roadtrip.stop.offRoad': '{distance} von der Straße',
'roadtrip.poi.rest': 'Rastplatz',
'roadtrip.poi.campsite': 'Campingplatz',
'roadtrip.poi.food': 'Essen',
diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts
index 65f37a383..5f55a2028 100644
--- a/shared/src/i18n/en/roadtrip.ts
+++ b/shared/src/i18n/en/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} on the way',
'roadtrip.poi.fuel': 'Fuel',
'roadtrip.poi.charging': 'Charging',
+ 'roadtrip.stop.offRoad': '{distance} from the road',
'roadtrip.poi.rest': 'Rest area',
'roadtrip.poi.campsite': 'Campsite',
'roadtrip.poi.food': 'Food',
diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts
index 8cc4a3218..633e1531e 100644
--- a/shared/src/i18n/es/roadtrip.ts
+++ b/shared/src/i18n/es/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} por el camino',
'roadtrip.poi.fuel': 'Gasolina',
'roadtrip.poi.charging': 'Recarga',
+ 'roadtrip.stop.offRoad': '{distance} desde la carretera',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts
index 6d59eb3d2..77918b195 100644
--- a/shared/src/i18n/fr/roadtrip.ts
+++ b/shared/src/i18n/fr/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} sur le trajet',
'roadtrip.poi.fuel': 'Carburant',
'roadtrip.poi.charging': 'Recharge',
+ 'roadtrip.stop.offRoad': '{distance} de la route',
'roadtrip.poi.rest': 'Aire de repos',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Restauration',
diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts
index 954a7ee1e..37643f19f 100644
--- a/shared/src/i18n/gr/roadtrip.ts
+++ b/shared/src/i18n/gr/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} στη διαδρομή',
'roadtrip.poi.fuel': 'Καύσιμα',
'roadtrip.poi.charging': 'Φόρτιση',
+ 'roadtrip.stop.offRoad': '{distance} από τον δρόμο',
'roadtrip.poi.rest': 'Χώρος ανάπαυσης',
'roadtrip.poi.campsite': 'Κάμπινγκ',
'roadtrip.poi.food': 'Φαγητό',
diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts
index 649ed0f23..2609b5240 100644
--- a/shared/src/i18n/hu/roadtrip.ts
+++ b/shared/src/i18n/hu/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} az úton',
'roadtrip.poi.fuel': 'Üzemanyag',
'roadtrip.poi.charging': 'Töltés',
+ 'roadtrip.stop.offRoad': '{distance} az úttól',
'roadtrip.poi.rest': 'Pihenőhely',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Étkezés',
diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts
index 4aafa2a80..90f806060 100644
--- a/shared/src/i18n/id/roadtrip.ts
+++ b/shared/src/i18n/id/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} di jalan',
'roadtrip.poi.fuel': 'Bahan bakar',
'roadtrip.poi.charging': 'Pengisian daya',
+ 'roadtrip.stop.offRoad': '{distance} dari jalan',
'roadtrip.poi.rest': 'Tempat istirahat',
'roadtrip.poi.campsite': 'Perkemahan',
'roadtrip.poi.food': 'Makanan',
diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts
index e3d868e79..ca7f71e23 100644
--- a/shared/src/i18n/it/roadtrip.ts
+++ b/shared/src/i18n/it/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} lungo il tragitto',
'roadtrip.poi.fuel': 'Carburante',
'roadtrip.poi.charging': 'Ricarica',
+ 'roadtrip.stop.offRoad': '{distance} dalla strada',
'roadtrip.poi.rest': 'Area di sosta',
'roadtrip.poi.campsite': 'Campeggio',
'roadtrip.poi.food': 'Ristoro',
diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts
index 34041766a..f4c1ff30c 100644
--- a/shared/src/i18n/ja/roadtrip.ts
+++ b/shared/src/i18n/ja/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '道中に {count} 件',
'roadtrip.poi.fuel': '給油',
'roadtrip.poi.charging': '充電',
+ 'roadtrip.stop.offRoad': '道路から{distance}',
'roadtrip.poi.rest': 'サービスエリア',
'roadtrip.poi.campsite': 'キャンプ場',
'roadtrip.poi.food': '食事',
diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts
index 522a8155e..e2da977c7 100644
--- a/shared/src/i18n/ko/roadtrip.ts
+++ b/shared/src/i18n/ko/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '경로에 {count}곳',
'roadtrip.poi.fuel': '주유',
'roadtrip.poi.charging': '충전',
+ 'roadtrip.stop.offRoad': '도로에서 {distance}',
'roadtrip.poi.rest': '휴게소',
'roadtrip.poi.campsite': '캠핑장',
'roadtrip.poi.food': '식사',
diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts
index 034887065..fe1b1a0f9 100644
--- a/shared/src/i18n/nl/roadtrip.ts
+++ b/shared/src/i18n/nl/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} onderweg',
'roadtrip.poi.fuel': 'Tanken',
'roadtrip.poi.charging': 'Laden',
+ 'roadtrip.stop.offRoad': '{distance} vanaf de weg',
'roadtrip.poi.rest': 'Rustplaats',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Eten',
diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts
index f64099a35..085c7ffde 100644
--- a/shared/src/i18n/pl/roadtrip.ts
+++ b/shared/src/i18n/pl/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} po drodze',
'roadtrip.poi.fuel': 'Paliwo',
'roadtrip.poi.charging': 'Ładowanie',
+ 'roadtrip.stop.offRoad': '{distance} od drogi',
'roadtrip.poi.rest': 'Miejsce odpoczynku',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Jedzenie',
diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts
index ca8739436..0aea3e957 100644
--- a/shared/src/i18n/ru/roadtrip.ts
+++ b/shared/src/i18n/ru/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} по пути',
'roadtrip.poi.fuel': 'Заправка',
'roadtrip.poi.charging': 'Зарядка',
+ 'roadtrip.stop.offRoad': '{distance} от дороги',
'roadtrip.poi.rest': 'Зона отдыха',
'roadtrip.poi.campsite': 'Кемпинг',
'roadtrip.poi.food': 'Еда',
diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts
index 7c4ab7bd0..f654de8ac 100644
--- a/shared/src/i18n/sv/roadtrip.ts
+++ b/shared/src/i18n/sv/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} längs vägen',
'roadtrip.poi.fuel': 'Bensin',
'roadtrip.poi.charging': 'Laddning',
+ 'roadtrip.stop.offRoad': '{distance} från vägen',
'roadtrip.poi.rest': 'Rastplats',
'roadtrip.poi.campsite': 'Campingplats',
'roadtrip.poi.food': 'Mat',
diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts
index 34e889c4b..aa07e6154 100644
--- a/shared/src/i18n/tr/roadtrip.ts
+++ b/shared/src/i18n/tr/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': 'yol üzerinde {count}',
'roadtrip.poi.fuel': 'Yakıt',
'roadtrip.poi.charging': 'Şarj',
+ 'roadtrip.stop.offRoad': 'yoldan {distance}',
'roadtrip.poi.rest': 'Dinlenme alanı',
'roadtrip.poi.campsite': 'Kamp alanı',
'roadtrip.poi.food': 'Yemek',
diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts
index 60d4919ca..f5938928d 100644
--- a/shared/src/i18n/uk/roadtrip.ts
+++ b/shared/src/i18n/uk/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} у дорозі',
'roadtrip.poi.fuel': 'Заправка',
'roadtrip.poi.charging': 'Зарядка',
+ 'roadtrip.stop.offRoad': '{distance} від дороги',
'roadtrip.poi.rest': 'Зона відпочинку',
'roadtrip.poi.campsite': 'Кемпінг',
'roadtrip.poi.food': 'Їжа',
diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts
index e1fcdc11d..3d1968233 100644
--- a/shared/src/i18n/vi/roadtrip.ts
+++ b/shared/src/i18n/vi/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '{count} trên đường',
'roadtrip.poi.fuel': 'Xăng dầu',
'roadtrip.poi.charging': 'Sạc điện',
+ 'roadtrip.stop.offRoad': '{distance} từ đường',
'roadtrip.poi.rest': 'Trạm dừng nghỉ',
'roadtrip.poi.campsite': 'Khu cắm trại',
'roadtrip.poi.food': 'Đồ ăn',
diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts
index 327107e8f..8867489a7 100644
--- a/shared/src/i18n/zh-TW/roadtrip.ts
+++ b/shared/src/i18n/zh-TW/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '沿途 {count} 處',
'roadtrip.poi.fuel': '加油',
'roadtrip.poi.charging': '充電',
+ 'roadtrip.stop.offRoad': '距道路 {distance}',
'roadtrip.poi.rest': '休息站',
'roadtrip.poi.campsite': '露營地',
'roadtrip.poi.food': '餐飲',
diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts
index 96a835fdd..c12463903 100644
--- a/shared/src/i18n/zh/roadtrip.ts
+++ b/shared/src/i18n/zh/roadtrip.ts
@@ -17,6 +17,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.poi.found': '沿途 {count} 处',
'roadtrip.poi.fuel': '加油',
'roadtrip.poi.charging': '充电',
+ 'roadtrip.stop.offRoad': '距道路 {distance}',
'roadtrip.poi.rest': '服务区',
'roadtrip.poi.campsite': '露营地',
'roadtrip.poi.food': '餐饮',