From 11f4c740e138a1b3b9472e53c0fbf1436882ab96 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 4 Sep 2026 21:35:37 +0200 Subject: [PATCH] feat(roadtrip): draw the last bit to a place the road does not reach A router snaps every waypoint to the nearest road before it starts, and OSRM does it with no distance limit at all. A viewpoint above a valley, a farmhouse down a track, a marina at the end of a pier: the drive to it really begins somewhere else, and the line said nothing about that. It either stopped short for no visible reason or appeared to cut across open ground. OSRM reports the snap position and its distance in every single answer, and this client has thrown both away since the first route was drawn. Reading them costs no request and no key. The gap is drawn as a dashed spur in the route's own blue, over the route rather than under it, because it is the one piece of the line that is not driving. Thirty metres is the floor, which is about a dual carriageway and its verge, so a place pinned off an aerial photo does not earn a dash. From a quarter of a kilometre the rail prints the distance too, because that is where "the car park is over there" turns into luggage, a gate, or a track a hire car should not be on. Vias are deliberately left out: a via is a shape handle, not a destination, and a dash hanging off one reads as a fault. --- client/src/components/Map/MapView.tsx | 15 +++++ client/src/components/Map/MapViewGL.tsx | 36 ++++++++++++ .../components/Map/RouteCalculator.test.ts | 55 ++++++++++++++++++- client/src/components/Map/RouteCalculator.ts | 37 ++++++++++++- .../components/Roadtrip/RoadtripSidebar.tsx | 14 +++++ .../components/Roadtrip/accessSpur.test.ts | 52 ++++++++++++++++++ client/src/components/Roadtrip/accessSpur.ts | 47 ++++++++++++++++ .../components/Roadtrip/useRoadtripRoutes.ts | 51 +++++++++++++++-- client/src/pages/TripPlannerPage.tsx | 1 + client/src/types.ts | 19 +++++++ shared/src/i18n/ar/roadtrip.ts | 1 + shared/src/i18n/br/roadtrip.ts | 1 + shared/src/i18n/ca/roadtrip.ts | 1 + shared/src/i18n/cs/roadtrip.ts | 1 + shared/src/i18n/de/roadtrip.ts | 1 + shared/src/i18n/en/roadtrip.ts | 1 + shared/src/i18n/es/roadtrip.ts | 1 + shared/src/i18n/fr/roadtrip.ts | 1 + shared/src/i18n/gr/roadtrip.ts | 1 + shared/src/i18n/hu/roadtrip.ts | 1 + shared/src/i18n/id/roadtrip.ts | 1 + shared/src/i18n/it/roadtrip.ts | 1 + shared/src/i18n/ja/roadtrip.ts | 1 + shared/src/i18n/ko/roadtrip.ts | 1 + shared/src/i18n/nl/roadtrip.ts | 1 + shared/src/i18n/pl/roadtrip.ts | 1 + shared/src/i18n/ru/roadtrip.ts | 1 + shared/src/i18n/sv/roadtrip.ts | 1 + shared/src/i18n/tr/roadtrip.ts | 1 + shared/src/i18n/uk/roadtrip.ts | 1 + shared/src/i18n/vi/roadtrip.ts | 1 + shared/src/i18n/zh-TW/roadtrip.ts | 1 + shared/src/i18n/zh/roadtrip.ts | 1 + 33 files changed, 342 insertions(+), 8 deletions(-) create mode 100644 client/src/components/Roadtrip/accessSpur.test.ts create mode 100644 client/src/components/Roadtrip/accessSpur.ts 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 (