mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user