Merge remote-tracking branch 'origin/dev' into feat/trip-route-overview

# Conflicts:
#	client/src/components/Map/MapView.tsx
#	client/src/components/Map/MapViewGL.tsx
#	client/src/pages/TripPlannerPage.tsx
#	client/src/pages/tripPlanner/useTripPlanner.ts
This commit is contained in:
Konstantinos Thermos
2026-09-11 16:22:21 +03:00
270 changed files with 14787 additions and 826 deletions
+20 -7
View File
@@ -18,7 +18,13 @@ const mapMock = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
// A flat projection, 1000 px per degree: far enough apart that every
// booking line clears its declutter floor, which is measured along the
// projected line (#2275) rather than read off a canned distanceTo.
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => ({
x: lng * 1000, y: lat * 1000,
distanceTo(other: { x: number; y: number }) { return Math.hypot(lng * 1000 - other.x, lat * 1000 - other.y) },
})),
// Panes: jsdom has none, so keep them in a map the pane tests can read back.
panes: new Map<string, HTMLElement>(),
getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }),
@@ -221,15 +227,22 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-006b: the whole-trip overview colours each line, on a white casing', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={['#ea580c']} />)
it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }]} />)
const [casing, core] = screen.getAllByTestId('polyline')
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
// White under the colour, the same trick the coloured GPX tracks use to stay
// readable on satellite and dark basemaps.
expect(casing.color).toBe('#ffffff')
expect(core.color).toBe('#ea580c')
expect(casing.color).toBe('#c2740a')
expect(core.color).toBe('#ff9f0a')
})
it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[undefined]} />)
const [casing, core] = screen.getAllByTestId('polyline')
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
expect(casing.color).toBe('#0a5cc2')
expect(core.color).toBe('#0a84ff')
})
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
+6 -7
View File
@@ -606,8 +606,10 @@ export const MapView = memo(function MapView({
places = [],
dayPlaces = [],
route = null,
// One colour pair per entry of `route`, or absent for the blue the route has always
// been. Only the road trip passes these, and only while colouring by day is on.
routeColors = null,
routeSegments = [],
routeColors,
selectedPlaceId = null,
hoverDisabled = false,
onMarkerClick,
@@ -1028,15 +1030,12 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on.
In the whole-trip overview each day is drawn in its own colour (#1736), and the
casing goes white — the same trick the coloured GPX tracks use to stay readable
on satellite and dark basemaps, where a per-colour dark casing would not. */}
the two, so it is the one a pointer actually lands on. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
<Polyline
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: routeColors?.[i] ? '#ffffff' : '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
@@ -1050,7 +1049,7 @@ export const MapView = memo(function MapView({
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: routeColors?.[i] ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
+70 -4
View File
@@ -1540,17 +1540,19 @@ describe('MapViewGL', () => {
expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]])
})
it('FE-COMP-MAPVIEWGL-048b: the whole-trip overview colours each line on the feature', async () => {
it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => {
const routeSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]} routeColors={['#1d4ed8', null]} />)
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]}
routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }, undefined]} />)
await act(async () => {})
const { features } = lastData(routeSource)
expect(features[0].properties.color).toBe('#1d4ed8')
expect(features[0].properties.color).toBe('#ff9f0a')
expect(features[0].properties.casing).toBe('#c2740a')
// No colour means the day route's own blue, which the layer paint falls back to.
expect(features[1].properties.color).toBeUndefined()
expect(features[1].properties.color).toBeNull()
})
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
@@ -1968,4 +1970,68 @@ describe('MapViewGL', () => {
// in the wrong place.
expect(layer.firstElementChild).not.toBe(handle)
})
// ── Satellite ───────────────────────────────────────────────────────────────
//
// Leaflet has had the imagery for a while and swaps its whole tile layer for it.
// A GL map cannot do that, because its basemap is a style with dozens of layers,
// so the imagery goes on as a raster layer under everything TREK draws.
it('FE-COMP-MAPVIEWGL-074: satellite adds the imagery under the first TREK layer', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({
layers: [{ id: 'background' }, { id: 'road' }, { id: 'trip-route' }, { id: 'trip-gpx-hit' }],
})
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({
type: 'raster',
tiles: [expect.stringContaining('arcgisonline.com')],
}))
// Anchored before the route, or the imagery would be painted over it.
expect(glMap.addLayer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
'trip-route',
)
})
it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
} as never)
render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
})
it('FE-COMP-MAPVIEWGL-076: switching back hides the layer instead of tearing it down', async () => {
loadOnAttach()
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
} as never)
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
// Present from the first pass on, the way the real map reports it afterwards.
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null))
const { rerender } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
act(() => {
useSettingsStore.setState({
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
} as never)
})
rerender(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
})
})
+142 -12
View File
@@ -8,6 +8,7 @@ import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { isCustomPlaceImage, photoCacheKey } from './placePhoto'
@@ -27,7 +28,7 @@ import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
@@ -48,6 +49,21 @@ const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
const GPX_HIT_LAYER_ID = 'trip-gpx-hit'
/**
* The satellite base layer, the GL twin of the Leaflet one.
*
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
* layer of its own, drawn over the style and under everything TREK adds — the route,
* the pins and the tracks stay on top and stay legible, which is the same order the
* Leaflet map ends up with.
*
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
*/
const SATELLITE_SOURCE_ID = 'trip-satellite'
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
const OWN_LAYER_PREFIXES = ['trip-', 'trek-']
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -107,9 +123,12 @@ interface Props {
/** 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
/**
* One colour pair per entry of `route`, or absent for the blue the route has always
* been. Only the road trip passes these, and only while colouring by day is on.
*/
routeColors?: ({ line: string; casing: string } | undefined)[] | null
routeSegments?: RouteSegment[]
/** Line colour per entry of `route`, same index — the whole-trip overview (#1736). */
routeColors?: (string | null)[]
selectedPlaceId?: number | null
onMarkerClick?: (id: number) => void
hoverDisabled?: boolean
@@ -235,6 +254,44 @@ function addPlaceClusterLayers(map: any): void {
})
}
/**
* Puts the imagery on the map, or takes it off again.
*
* Built on demand rather than once at load: a style change drops every source the map
* had, and a hot reload does the same. Guarded on its own absence, so the usual pass is
* a no-op and only the visibility flips.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applySatellite(map: any, on: boolean): void {
try {
if (!map.isStyleLoaded?.()) return
if (!map.getSource(SATELLITE_SOURCE_ID)) {
if (!on) return // Nothing to build while it is switched off.
map.addSource(SATELLITE_SOURCE_ID, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: SATELLITE_TILE_MAXZOOM,
attribution: SATELLITE_TILE_ATTRIBUTION,
})
// Under the first thing TREK draws, over everything the basemap style draws.
// Without the anchor the imagery lands on top and buries the route.
const layers = map.getStyle?.()?.layers ?? []
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
}
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
}
} catch { /* a style that refuses the layer keeps the plain basemap */ }
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
@@ -554,8 +611,8 @@ export function MapViewGL({
routeVias = NO_ROUTE_VIAS,
accessLines = NO_ACCESS_LINES,
route = null,
routeColors = null,
routeSegments = NO_ROUTE_SEGMENTS,
routeColors,
selectedPlaceId = null,
hoverDisabled = false,
onMarkerClick,
@@ -597,6 +654,15 @@ export function MapViewGL({
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
// The same stored choice the Leaflet map reads, so the two renderers agree.
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
const isSatellite = baseLayer === 'satellite'
const toggleBaseLayer = useCallback(() => {
// The store flips synchronously, so the map switches even offline; a failed save
// is logged there rather than blocking the switch.
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
}, [isSatellite, updateSetting])
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
@@ -702,6 +768,23 @@ export function MapViewGL({
viaCleanupRef.current = []
viaPinsRef.current.forEach(p => p.remove())
viaPinsRef.current = []
/**
* Zoomed out, the handles go away.
*
* A via is a handle for a few hundred metres of road, and at a continental zoom a
* whole day's worth of them collapses into a cluster of dots over one town — not
* something anybody can aim at, and dragging one there moves the route by kilometres
* per pixel. Below this the drive is read, not shaped.
*/
const VIA_MIN_ZOOM = 9
const applyViaZoom = () => {
const on = map.getZoom() >= VIA_MIN_ZOOM
for (const pin of viaPinsRef.current) {
const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.()
if (node) node.style.display = on ? '' : 'none'
}
}
for (const via of vias) {
const el = document.createElement('span')
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
@@ -790,8 +873,26 @@ export function MapViewGL({
el.removeEventListener('dblclick', swallow)
})
}
// Applied now and on every zoom that settles, so a handle that should not be there is
// gone before the first frame rather than after the first gesture.
applyViaZoom()
map.on('zoomend', applyViaZoom)
viaCleanupRef.current.push(() => map.off('zoomend', applyViaZoom))
}, [vias, viasDraggable, mapReady, glProvider])
/**
* The other people's pointers, and this person's own going the other way.
*
* Built as plain elements on the same pin layer the vias use, so they ride the map
* during a pan instead of being re-placed a frame later — the same reason the via
* handles are not library markers.
*
* No interpolation between frames, unlike the studio's book: a map pans and zooms under
* the arrow, so a position eased towards over several frames is a position that was
* never true at any of them. Ten frames a second placed exactly reads as a hand; the
* same frames chasing a moving target read as a drift.
*/
/**
* The offered routes, drawn under the current one.
*
@@ -1118,7 +1219,9 @@ export function MapViewGL({
id: 'trip-route-casing',
type: 'line',
source: 'trip-route',
paint: { 'line-color': ['case', ['has', 'color'], '#ffffff', '#0a5cc2'], 'line-width': 8 },
// Per feature where the caller gave one, else the blue the route has always
// been. `coalesce` rather than a second layer: one source, one stroke.
paint: { 'line-color': ['coalesce', ['get', 'casing'], '#0a5cc2'], 'line-width': 8 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// An invisible band over the route, purely to be clicked. The drawn line is 8px
@@ -1825,13 +1928,16 @@ export function MapViewGL({
if (!map) return
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
if (!src) return
// The colour rides on the feature rather than the layer so one source can hold a
// whole trip of differently-coloured days (#1736), the way trip-gpx already does.
const features = (route || []).flatMap((seg, i) => seg && seg.length > 1 ? [{
type: 'Feature' as const,
properties: routeColors?.[i] ? { color: routeColors[i] } : {},
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
}] : [])
const features = (route || [])
.map((seg, i) => ({ seg, colors: routeColors?.[i] }))
.filter(({ seg }) => seg && seg.length > 1)
.map(({ seg, colors }) => ({
type: 'Feature' as const,
// Null rather than absent: `coalesce` in the paint expression falls through on
// null, and an absent property would make every line the default colour.
properties: { color: colors?.line ?? null, casing: colors?.casing ?? null },
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
}))
src.setData({ type: 'FeatureCollection', features })
}, [route, routeColors, mapReady])
@@ -2063,6 +2169,18 @@ export function MapViewGL({
else map.once('load', apply)
}, [userPosition, trackingMode, glProvider])
// Satellite, the same setting the Leaflet map reads, so switching it on one renderer
// and reloading into the other keeps the choice. `styledata` is subscribed because a
// basemap change rebuilds the style from scratch and takes the imagery with it.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const apply = () => applySatellite(map, isSatellite)
apply()
map.on('styledata', apply)
return () => { map.off('styledata', apply) }
}, [isSatellite, mapReady, glProvider])
if (!isMapLibre && !mapboxToken) {
return (
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
@@ -2098,6 +2216,18 @@ export function MapViewGL({
bottomOffset={buttonBottom as unknown as number}
/>
)}
{/* Same pill, same corner, same offsets as the Leaflet map: the switch should not
move when an instance changes renderer. The day panel is a centred card on the
desktop map and never reaches the pill, but on a phone it is full width and
does, which is why the lift is only there. */}
<div style={{
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
bottom: isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
}}>
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
the Leaflet map's overlay (no anchored popup, no photo). */}
{!hoverDisabled && hoverPlace && hoverPos && !isMobile && (
@@ -1,4 +1,4 @@
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-024
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-045
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -389,6 +389,43 @@ describe('ReservationOverlay declutter floors and road routes', () => {
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual([[48, 16], [51.5, 16]])
})
it('FE-COMP-RESOVERLAY-043: a routed drive whose ends project close together still draws (#2275)', () => {
// Endpoints 50 px apart, under the 80 px car floor, so the straight hop would
// hide. The road between them loops out 0.3° (300 px) and back, which is what
// is actually drawn — and what the person was missing on their map.
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
const { unmount } = renderOverlay({ reservations: [hop('car', 7, 0.05)] })
expect(lines()).toHaveLength(0)
unmount()
renderOverlay({ reservations: [hop('car', 7, 0.05)], roadRoutes: new Map([[7, road]]) })
expect(lines()).toHaveLength(1)
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual(road)
})
it('FE-COMP-RESOVERLAY-044: a drive with a stop on the way is measured along its legs, not its ends', () => {
// From and to sit 20 px apart; the stop is 400 px away, so the two legs
// together draw 800 px of line.
renderOverlay({
reservations: [booking({
id: 8, type: 'car',
endpoints: [
endpoint({ role: 'from', sequence: 0, name: 'Home', lat: 48, lng: 16 }),
endpoint({ role: 'stop', sequence: 1, name: 'Lake', lat: 48.4, lng: 16 }),
endpoint({ role: 'to', sequence: 2, name: 'Back', lat: 48.02, lng: 16 }),
],
})],
})
expect(lines()).toHaveLength(2)
})
it('FE-COMP-RESOVERLAY-045: a routed drive that really is tiny on screen stays decluttered', () => {
// Every point of the road projects within 30 px: nothing worth drawing yet.
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
renderOverlay({ reservations: [hop('car', 7, 0.03)], roadRoutes: new Map([[7, road]]) })
expect(lines()).toHaveLength(0)
})
it('FE-COMP-RESOVERLAY-036: a booking without an endpoints array is skipped', () => {
renderOverlay({ reservations: [booking({ endpoints: undefined })] })
expect(lines()).toHaveLength(0)
@@ -8,6 +8,7 @@ import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
import { useSettingsStore } from '../../store/settingsStore'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import type { Reservation, ReservationEndpoint } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints'
@@ -122,6 +123,12 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
const road = roadRoutes?.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
}
interface TransportItem {
res: Reservation
from: ReservationEndpoint
@@ -200,26 +207,25 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
}, [reservations])
const visibleItems = useMemo(() => {
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
return items.filter(item => {
// A transit journey draws its real rail/bus alignment, not a straight from->to
// line, so the endpoint-proximity declutter (which exists to hide tiny no-op
// straight connectors) must not suppress it. Otherwise a zoomed-out day — e.g. one
// with no other places to tighten the map onto — hides the whole route (#1570).
if (item.transitSegs.length > 0) return true
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return fromPx.distanceTo(toPx) >= minPx
// Measured along what is drawn, not between the ends: a routed drive can
// cover half the screen while its endpoints sit close together (#2275).
return hopIsVisible(item.type, linesFor(item, roadRoutes), project)
})
}, [items, zoom, map])
}, [items, zoom, map, roadRoutes])
const labelVisibleIds = useMemo(() => {
const set = new Set<number>()
for (const item of visibleItems) {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
if (fromPx.distanceTo(toPx) >= labelFloorPx(item.type)) set.add(item.res.id)
}
return set
}, [visibleItems, zoom, map])
@@ -248,9 +254,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
const road = roadRoutes?.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map((seg, segIdx) => (
return linesFor(item, roadRoutes).map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
@@ -1,5 +1,5 @@
import React, { useMemo, useRef } from 'react'
import { Marker, Tooltip } from 'react-leaflet'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Marker, Tooltip, useMap } from 'react-leaflet'
import L from 'leaflet'
import { useTranslation } from '../../i18n/TranslationContext'
import { useStableVias } from './viaMarkerState'
@@ -31,6 +31,9 @@ interface RoadtripViaMarkersProps {
}
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
/** Below this the handles are not drawn. Shared with the GL renderer's own copy. */
const VIA_MIN_ZOOM = 9
export default function RoadtripViaMarkers({
viasByDay, onMoveVia, onRemoveVia,
}: RoadtripViaMarkersProps): React.ReactElement | null {
@@ -44,6 +47,22 @@ export default function RoadtripViaMarkers({
const handlersRef = useRef({ onMoveVia, onRemoveVia })
handlersRef.current = { onMoveVia, onRemoveVia }
const draggable = !!onMoveVia
/**
* Zoomed out, the handles go away.
*
* A via is a handle for a few hundred metres of road, and at a continental zoom a whole
* day's worth collapses into a cluster of dots over one town — not something anybody can
* aim at, and a drag there moves the route by kilometres per pixel. Below this the drive
* is read, not shaped.
*/
const map = useMap()
const [zoomedIn, setZoomedIn] = useState(() => map.getZoom() >= VIA_MIN_ZOOM)
useEffect(() => {
const update = () => setZoomedIn(map.getZoom() >= VIA_MIN_ZOOM)
update()
map.on('zoomend', update)
return () => { map.off('zoomend', update) }
}, [map])
const markers = useMemo(() => vias.map(via => ({
via,
@@ -62,7 +81,7 @@ export default function RoadtripViaMarkers({
},
})), [vias])
if (!markers.length) return null
if (!markers.length || !zoomedIn) return null
return (
<>
@@ -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<string, unknown>) => ({
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<string, number> } }
// 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
+268 -26
View File
@@ -3,6 +3,9 @@ import { pluginsApi } from '../../api/client'
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
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
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
@@ -50,6 +53,44 @@ function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
}
/**
* `fetch`, with the request counted.
*
* Every route TREK draws goes out from here, and nowhere else, which makes this the one
* place that can answer how much routing an instance really does — the number behind
* "could we host an engine ourselves". Counted: how many requests, of what kind, how
* many waypoints, roughly how far, and whether the host refused. Not counted, because it
* is never sent: where any of it was.
*
* Distance is the straight line along the waypoint chain rather than the routed length,
* which is only in the answer and differs per response shape. It is a floor on the real
* figure, and a floor is enough to tell a 1500 km per-request limit from a 150 km one.
*
* An aborted request is not a failure: the map cancels constantly while someone drags.
*/
async function routedFetch(
url: string,
signal: AbortSignal | undefined,
kind: RouteUsageSurface,
profile: 'driving' | 'walking' | 'cycling',
waypoints: readonly Waypoint[],
): Promise<Response> {
const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim()
let km = 0
for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i])
const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km }
try {
const response = await fetch(url, { signal })
countRoute({ ...sample, failed: !response.ok })
return response
} catch (err) {
if (!(err instanceof DOMException && err.name === 'AbortError')) {
countRoute({ ...sample, failed: true })
}
throw err
}
}
/**
* A routing request the host refused, as opposed to one it answered with "no route".
*
@@ -114,7 +155,7 @@ export async function calculateRoute(
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'route', profile, waypoints)
if (!response.ok) {
throw new RoutingRefusedError(response.status, retryAfterMs(response))
}
@@ -321,7 +362,7 @@ export async function calculateSegments(
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -352,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<RouteWithLegs> {
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:<id>/<profile>`): 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.
@@ -416,7 +489,7 @@ export async function calculateRouteWithLegs(
const osrmProfile: 'driving' | 'walking' | 'cycling' =
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'legs', osrmProfile, waypoints)
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -446,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<RouteWithLegs | null> {
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)) },
}
}
/**
@@ -514,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
}
/**
@@ -528,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
}
@@ -563,16 +725,76 @@ const excludeUnsupported = new Set<string>()
* 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<RouteAlternative | null> {
// 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<RouteAlternative | null> {
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<RouteAlternative | null> {
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`
const response = await fetch(url, { signal })
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.
@@ -614,7 +836,7 @@ export async function calculateAlternatives(
): Promise<RouteAlternative[]> {
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
const response = await fetch(url, { signal })
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
const data = await response.json()
@@ -629,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)
@@ -659,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 }
}
@@ -5,8 +5,8 @@ import type { TripRouteOverview } from './useTripRouteOverview'
const overview = (over: Partial<TripRouteOverview> = {}): TripRouteOverview => ({
days: [
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: '#1d4ed8', lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: '#ea580c', lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
],
lines: [], lineColors: [], segments: [], focusPoints: [],
totalDistance: 222500, totalDuration: 10800, loading: false,
@@ -94,7 +94,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
const row = (
<>
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color, flexShrink: 0 }} aria-hidden />
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
<span className="text-content" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</span>
@@ -613,6 +613,26 @@ describe('ReservationMapboxOverlay declutter floors', () => {
attach(wide, labelled).update([hop(2, 'cruise', 0.4)], labelled)
expect(glMarkers.map(m => m.el.textContent)).toEqual(['GOA', 'PMI'])
})
it('FE-COMP-RESGL-043: a routed drive whose ends project close together still draws (#2275)', () => {
// 50 px between the ends, under the 80 px car floor; the road loops out 300 px.
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
const bare = freshMap()
attach(bare).update([hop(7, 'car', 0.05)], opts)
expect(features(bare)).toHaveLength(0)
const routed = freshMap()
attach(routed).update([hop(7, 'car', 0.05)], opts, new Map([[7, road]]))
expect(features(routed)).toHaveLength(1)
expect(features(routed)[0].geometry.coordinates).toEqual(road.map(([lat, lng]) => [lng, lat]))
})
it('FE-COMP-RESGL-044: a routed drive that really is tiny on screen stays decluttered', () => {
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
const map = freshMap()
attach(map).update([hop(7, 'car', 0.03)], opts, new Map([[7, road]]))
expect(features(map)).toHaveLength(0)
})
})
describe('ReservationMapboxOverlay waypoint edge cases', () => {
+16 -14
View File
@@ -13,6 +13,7 @@ import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, Tram
import { getTransitMapSegments } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
@@ -259,27 +260,31 @@ export class ReservationMapboxOverlay {
})
}
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
private linesFor(item: TransportItem): [number, number][][] {
const road = this.roadRoutes.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
}
private render() {
const map = this.map
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
const show = this.opts.showConnections
// Visible filter: require the on-screen pixel distance between
// endpoints to exceed a type-specific minimum, same as the Leaflet
// overlay, so tiny no-op transport lines don't clutter the map.
// Visible filter: a hop draws once what it will draw is long enough on
// screen to be worth it, same as the Leaflet overlay, so tiny no-op
// transport lines don't clutter the map. Measured along the drawn line,
// not between the ends: a routed drive can cover half the screen while
// its endpoints sit close together (#2275).
const project = (p: readonly [number, number]) => map.project([p[1], p[0]])
const visibleItems = show ? this.items.filter(item => {
try {
// A transit journey draws its real alignment, not a straight from->to line, so
// don't let the endpoint-proximity declutter hide it when the map is zoomed out
// (#1570). Mirrors the Leaflet overlay.
if (item.type === 'transit' && getTransitMapSegments(item.res).length > 0) return true
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return dist >= minPx
return hopIsVisible(item.type, this.linesFor(item), project)
} catch { return true }
}) : []
@@ -293,8 +298,7 @@ export class ReservationMapboxOverlay {
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
if (dist >= minPx) labelVisibleIds.add(item.res.id)
if (dist >= labelFloorPx(item.type)) labelVisibleIds.add(item.res.id)
} catch { /* ignore */ }
}
}
@@ -320,9 +324,7 @@ export class ReservationMapboxOverlay {
}))
}
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
const road = this.roadRoutes.get(item.res.id)
const lines = road && road.length >= 2 ? [road] : item.arcs
return lines.map(seg => ({
return this.linesFor(item).map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { countRoute, flushRouteUsage } from './routeUsageCounter'
import { routeUsageApi } from '../../api/routeUsage'
/**
* FE-MAP-ROUTECOUNT-001..006 — the tally behind "how much routing does an instance do".
*
* Two things are worth pinning: that it batches rather than posting per route, because a
* counter that costs a request of its own measures the load by adding to it, and that it
* never throws — it sits on the hot path of every route the map draws.
*/
const sample = {
profile: 'driving' as const,
surface: 'legs' as const,
selfHosted: false,
waypoints: 3,
km: 100,
failed: false,
}
describe('routeUsageCounter', () => {
beforeEach(() => {
vi.spyOn(routeUsageApi, 'report').mockResolvedValue(undefined)
vi.useFakeTimers()
})
afterEach(() => {
flushRouteUsage()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('FE-MAP-ROUTECOUNT-001: a single route posts nothing on its own', () => {
countRoute(sample)
expect(routeUsageApi.report).not.toHaveBeenCalled()
})
it('FE-MAP-ROUTECOUNT-002: the tally goes out once the timer runs out', () => {
countRoute(sample)
vi.advanceTimersByTime(60_000)
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries).toEqual([{ ...sample, requests: 1, failed: 0 }])
})
it('FE-MAP-ROUTECOUNT-003: routes of one kind add onto one entry', () => {
countRoute(sample)
countRoute({ ...sample, waypoints: 5, km: 200 })
countRoute({ ...sample, failed: true })
flushRouteUsage()
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({ requests: 3, waypoints: 11, km: 400, failed: 1 })
})
it('FE-MAP-ROUTECOUNT-004: a different kind or engine is its own entry', () => {
countRoute(sample)
countRoute({ ...sample, surface: 'alternatives' })
countRoute({ ...sample, selfHosted: true })
countRoute({ ...sample, profile: 'walking' })
flushRouteUsage()
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries).toHaveLength(4)
})
it('FE-MAP-ROUTECOUNT-005: a busy stretch flushes on its own, without waiting', () => {
for (let i = 0; i < 25; i++) countRoute(sample)
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
expect(entries[0].requests).toBe(25)
})
it('FE-MAP-ROUTECOUNT-006: a refused report is swallowed, and the next tally starts clean', async () => {
vi.mocked(routeUsageApi.report).mockRejectedValueOnce(new Error('offline'))
countRoute(sample)
expect(() => flushRouteUsage()).not.toThrow()
await Promise.resolve()
countRoute(sample)
flushRouteUsage()
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[1]
expect(entries[0].requests).toBe(1)
})
})
@@ -0,0 +1,102 @@
import type { RouteUsageEntry, RouteUsageProfile, RouteUsageSurface } from '@trek/shared'
import { routeUsageApi } from '../../api/routeUsage'
/**
* Counts the routing this browser asks for, and posts the totals in batches.
*
* All routing happens client-side against an external engine, so the server never
* sees a route request and cannot count one. Without a count nobody knows what a
* self-hosted router would have to carry — how many requests a real trip costs, how
* big they are, how much already goes to an operator's own engine.
*
* Counters only. What goes over the wire is four integers per bucket: how many
* requests, how many waypoints, how many kilometres, how many failed. No coordinate,
* no route, no trip, nothing that says where anybody went.
*
* Batched on purpose. A counter that cost a request of its own would measure the load
* by adding to it, so the tally is held in memory and flushed on a timer, when it
* grows past a threshold, and when the page goes away.
*/
/** Flush when the tally has this many requests in it, whichever comes first. */
const FLUSH_AFTER_REQUESTS = 25
/** …or after this long, so a quiet session still reports what it did. */
const FLUSH_AFTER_MS = 60_000
/** Guard against a pathological session: drop the tally rather than grow forever. */
const MAX_BUCKETS = 40
type Bucket = Omit<RouteUsageEntry, 'profile' | 'surface' | 'selfHosted'> & {
profile: RouteUsageProfile
surface: RouteUsageSurface
selfHosted: boolean
}
const buckets = new Map<string, Bucket>()
let pending = 0
let timer: ReturnType<typeof setTimeout> | null = null
let unloadWired = false
function flush(): void {
if (timer !== null) { clearTimeout(timer); timer = null }
if (!buckets.size) return
const entries = [...buckets.values()]
buckets.clear()
pending = 0
// Fire and forget: a failed report is a lost count, not a problem worth telling
// anybody about. The next flush carries on from an empty tally either way.
void routeUsageApi.report(entries).catch(() => {})
}
function wireUnload(): void {
if (unloadWired || typeof document === 'undefined') return
unloadWired = true
// `visibilitychange`, not `unload`: the latter no longer fires reliably on mobile,
// where a session ends by switching apps rather than by closing a tab.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush()
})
}
/**
* Records one routing request. Never throws and never awaits: it sits on the hot path
* of every route the map draws.
*/
export function countRoute(sample: {
profile: RouteUsageProfile
surface: RouteUsageSurface
selfHosted: boolean
waypoints: number
km: number
failed: boolean
}): void {
try {
wireUnload()
const key = `${sample.profile}|${sample.surface}|${sample.selfHosted}`
const bucket = buckets.get(key)
if (bucket) {
bucket.requests += 1
bucket.waypoints += sample.waypoints
bucket.km += sample.km
if (sample.failed) bucket.failed += 1
} else {
if (buckets.size >= MAX_BUCKETS) return
buckets.set(key, {
profile: sample.profile,
surface: sample.surface,
selfHosted: sample.selfHosted,
requests: 1,
waypoints: sample.waypoints,
km: sample.km,
failed: sample.failed ? 1 : 0,
})
}
pending += 1
if (pending >= FLUSH_AFTER_REQUESTS) { flush(); return }
if (timer === null) timer = setTimeout(flush, FLUSH_AFTER_MS)
} catch { /* counting must never break routing */ }
}
/** Flushes now. Exported for tests and for a deliberate "report before you go". */
export function flushRouteUsage(): void {
flush()
}
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useMergedMapPois } from './useMergedMapPois'
import type { Poi } from './poiCategories'
/**
* FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode.
*
* The corridor answers "what is along the drive", the category pill answers "what is in
* view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is
* only new when the content really is — the map rebuilds every pin off it.
*/
const poi = (osm_id: string, name = osm_id): Poi => ({
osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap',
} as Poi)
describe('useMergedMapPois', () => {
it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => {
const explore = [poi('n1')]
const { result } = renderHook(() => useMergedMapPois(null, explore))
expect(result.current).toBe(explore)
})
it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => {
const corridor = [poi('n1')]
const { result } = renderHook(() => useMergedMapPois(corridor, []))
expect(result.current).toBe(corridor)
})
it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => {
const explore = [poi('n2')]
const { result } = renderHook(() => useMergedMapPois([], explore))
expect(result.current).toBe(explore)
})
it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => {
const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')]))
expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2'])
})
it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => {
// The corridor copy carries how far along the drive it sits, which the pill's does not.
const corridor = [poi('n1', 'Hotel am Weg')]
const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')]))
expect(result.current).toBe(corridor)
expect(result.current).toHaveLength(1)
expect(result.current[0].name).toBe('Hotel am Weg')
})
it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => {
const corridor = [poi('n1')]
const explore = [poi('n2')]
const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore))
const first = result.current
rerender()
expect(result.current).toBe(first)
})
})
describe('useMergedMapPois — the refuel offers', () => {
const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi)
it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => {
// In road trip mode this is the only channel a POI reaches the map through, so
// without it somebody is asked to accept a stop they cannot see.
const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')]))
expect(result.current.map(p => p.osm_id)).toEqual(['offer'])
})
it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => {
const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')]))
expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b'])
})
it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => {
const corridor = [poi('a')]
const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), {
initialProps: { o: [] as Poi[] },
})
const first = result.current
rerender({ o: [] })
expect(result.current).toBe(first)
expect(result.current).toBe(corridor)
})
})
@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import type { Poi } from './poiCategories'
/**
* The findings the map should draw, when two searches can be running at once.
*
* In road trip mode the corridor search answers "what is along this drive". The category
* pill answers a different question — "what is in view right now" — and a hotel at tonight's
* stop is exactly the case where someone wants the second one without giving up the first,
* so the two lists are drawn together rather than one replacing the other.
*
* Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
* copy wins because it carries how far along the drive it sits.
*
* `offered` is the third case: the handful of filling stations proposed where a tank runs
* out. They belong on the map for a plain reason — somebody is being asked to accept a
* stop, and a stop you cannot see is not one you can judge — but they are not a search
* result and must not survive the panel being closed, which is why they arrive separately
* rather than being folded into the corridor's own list.
*
* Returns the input array itself whenever there is nothing to merge. The map redraws off
* this reference, so handing it a fresh array on every render would rebuild every pin.
*/
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] {
return useMemo(() => {
const base = corridor === null ? explore : merge(corridor, explore)
return merge(base, offered)
}, [corridor, explore, offered])
}
/** Nothing offered, as one stable array, so the default never changes identity. */
const NONE: Poi[] = []
/** `a` with everything from `b` that it does not already carry, by `osm_id`. */
function merge(a: Poi[], b: Poi[]): Poi[] {
if (!b.length) return a
if (!a.length) return b
const seen = new Set(a.map(p => p.osm_id))
const extra = b.filter(p => !seen.has(p.osm_id))
return extra.length ? [...a, ...extra] : a
}
@@ -1,6 +1,6 @@
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { TRACK_COLORS } from '@trek/shared'
import { dayColor } from '../Roadtrip/dayColors'
import { useTripRouteOverview } from './useTripRouteOverview'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { AssignmentsMap, RouteSegment } from '../../types'
@@ -64,16 +64,18 @@ describe('useTripRouteOverview', () => {
expect(result.current.totalDuration).toBe(1800)
})
it('FE-MAP-TRO-003: gives each day its own colour from the map palette', async () => {
it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
const colors = result.current.days.map(d => d.color)
expect(colors[0]).toBe(TRACK_COLORS[1])
expect(colors[1]).toBe(TRACK_COLORS[2])
// The same palette either way, so a day does not change colour when the trip is
// read as a road trip instead of an overview.
expect(colors[0]).toEqual(dayColor(1))
expect(colors[1]).toEqual(dayColor(2))
// Every drawn line is labelled with the colour of the day it belongs to.
expect(result.current.lineColors).toHaveLength(result.current.lines.length)
expect(new Set(result.current.lineColors).size).toBe(2)
expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2)
})
it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => {
@@ -3,7 +3,7 @@ import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
import { resolveLegMode } from '../Planner/legMode'
import { useSettingsStore } from '../../store/settingsStore'
import { TRACK_COLORS } from '@trek/shared'
import { dayColor } from '../Roadtrip/dayColors'
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
/** One travel day of the overview: the roads it covers, in its own colour. */
@@ -12,7 +12,9 @@ export interface TripOverviewDay {
dayNumber: number
date: string | null
title: string | null
color: string
/** The core and casing this day is drawn in — the road trip's palette, so a day is
* the same colour whichever way the trip is being read. */
color: { line: string; casing: string }
/** One polyline per run of the day, `[lat, lng]`. */
lines: [number, number][][]
segments: RouteSegment[]
@@ -28,7 +30,7 @@ export interface TripRouteOverview {
/** Every day's polylines flattened in trip order — what the map draws. */
lines: [number, number][][]
/** The colour of each entry of `lines`, same index. */
lineColors: string[]
lineColors: { line: string; casing: string }[]
segments: RouteSegment[]
/** Every drawn coordinate, so the map can frame the whole trip at once. */
focusPoints: [number, number][]
@@ -116,7 +118,7 @@ export function useTripRouteOverview(
const controller = new AbortController()
abortRef.current = controller
const colorFor = (day: Day) => TRACK_COLORS[Math.abs(day.day_number ?? 0) % TRACK_COLORS.length]
const colorFor = (day: Day) => dayColor(day.day_number ?? 0)
// Chunk every day up front, so the routed answers can be filed by position and
// reassembled in order however they come back.
@@ -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<string, unknown> = {}) => ({
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<string, string>) =>
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<string, unknown> | null = null
server.use(
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
body = await request.json() as Record<string, unknown>
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<string, unknown>[] = []
server.use(
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
seen.push(await request.json() as Record<string, unknown>)
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()
})
})
+411
View File
@@ -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<AvoidClass, string> = {
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<string>()
/**
* 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<ValhallaLeg | null> {
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<ValhallaRun | null> {
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<void> {
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<Omit<ValhallaRun, 'snapped'> | 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<string, number> = {}
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<ValhallaRun, 'snapped'> | 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
}