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 (