fix(roadtrip): search along the roads driven, not the lines between stops

The corridor was built from the day's stops, so it followed straight lines across
country the car never drives. Between two cities that line runs through open fields
while the motorway swings wide of it, which is why every hit came back sitting on a
stop rather than on the way.

The days now carry the routed geometry, and the corridor is cut from that. Same trip,
same search: 192 hits spread over the whole 482 km — first fifty, middle, and the last
stretch — where before all of them reported kilometre zero.

Following real roads takes more boxes to cover the same drive, so they go out three at
a time instead of strictly one after another, and the budget rises from ten to sixteen.
The panel head loses the sentence explaining what the sections below already say, and
the day's distance and driving time, which the day list has been showing all along.
This commit is contained in:
Maurice
2026-08-28 22:46:52 +02:00
committed by Maurice
parent 647e972c8b
commit 552bd61d11
27 changed files with 46 additions and 52 deletions
@@ -7,7 +7,6 @@ import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import CustomSelect from '../shared/CustomSelect'
import { formatDurationShort } from './roadtripModel'
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
import type { CorridorPoi } from './useCorridorPois'
import type { RoadtripRoutes } from './useRoadtripRoutes'
@@ -119,18 +118,8 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
return (
<div className="flex h-full min-h-0 flex-col">
{/* Header — the rhythm of the places sidebar this replaces. */}
<div className="flex-shrink-0 border-b border-edge-faint px-4 pb-3 pt-3.5">
<div className="flex-shrink-0 border-b border-edge-faint px-4 pb-2.5 pt-3.5">
<h2 className="text-subtitle font-semibold text-content">{t('roadtrip.poi.title')}</h2>
<p className="mt-0.5 text-caption text-content-muted">{t('roadtrip.poi.hint')}</p>
{corridor.day ? (
// Under the title rather than beside it: the rail is narrow enough that a
// second element on the heading line pushes the heading into two.
<p className="mt-1.5 text-caption text-content-faint tabular-nums">
{formatDistance(corridor.day.distance / 1000, distanceUnit)}
<span className="mx-1">·</span>
{formatDurationShort(corridor.day.duration)}
</p>
) : null}
</div>
{/* Search controls */}
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { corridorTiles, projectOntoRoute, simplifyLine, type LatLng } from './corridor'
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
import type { Poi } from '../Map/poiCategories'
/** A POI that is actually on the way, with the two numbers that make it one. */
@@ -27,9 +27,11 @@ export interface CorridorSearch {
* would otherwise be dozens of requests against a shared public mirror; past this the
* search stops and says so rather than quietly covering half the route.
*/
const MAX_TILES = 10
/** Gap between Overpass requests — the public mirrors are a shared resource. */
const REQUEST_SPACING_MS = 700
const MAX_TILES = 16
/** Gap after a request before the same worker starts the next — the mirrors are shared. */
const REQUEST_SPACING_MS = 400
/** How many boxes are in flight at once. */
const CONCURRENT_REQUESTS = 3
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
new Promise(resolve => {
@@ -93,13 +95,23 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
setLoading(true)
setProgress({ done: 0, total: tiles.length * categories.length })
const jobs: { tile: Bbox; category: string }[] = []
for (const tile of tiles) for (const category of categories) jobs.push({ tile, category })
void (async () => {
const seen = new Map<string, CorridorPoi>()
let failures = 0
let done = 0
for (const tile of tiles) {
for (const category of categories) {
let next = 0
// A few at a time, staggered. Following the real road rather than the line between
// stops means more boxes to cover the same drive, and asking for them strictly one
// after another turned a search into half a minute of waiting. The server races
// several Overpass mirrors and caches what comes back, so a small pool is fair use.
const worker = async (): Promise<void> => {
while (next < jobs.length) {
if (controller.signal.aborted || runId !== runIdRef.current) return
const { tile, category } = jobs[next++]
try {
const data = await mapsApi.pois(category, tile, locale, controller.signal)
for (const poi of data.pois) {
@@ -109,19 +121,21 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
seen.set(poi.osm_id, { ...poi, offRouteKm: hit.offRouteKm, alongKm: hit.alongKm })
}
} catch {
// One dead mirror or one clamped box must not empty the whole search; the
// run is only called a failure if every request failed.
// One dead mirror or one clamped box must not empty the whole search; the run
// is only called a failure if every request failed.
failures++
}
done++
if (controller.signal.aborted || runId !== runIdRef.current) return
setProgress({ done, total: tiles.length * categories.length })
setProgress({ done, total: jobs.length })
setResults([...seen.values()].sort((a, b) => a.alongKm - b.alongKm))
await sleep(REQUEST_SPACING_MS, controller.signal)
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENT_REQUESTS, jobs.length) }, worker))
if (controller.signal.aborted || runId !== runIdRef.current) return
setError(failures === tiles.length * categories.length && failures > 0)
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
}, [line, categories, widthKm, locale])
@@ -37,12 +37,15 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor {
// has picked anything — and follows along when that day disappears.
const day = routes.days.find(d => String(d.dayId) === dayId) ?? routes.days[0]
const line = useMemo<LatLng[]>(
// The stops are spine enough: the corridor is kilometres wide, and the routed
// geometry per leg is not kept once its numbers have been read.
() => (day ? day.stops.map(s => ({ lat: s.lat, lng: s.lng })) : []),
[day],
)
const line = useMemo<LatLng[]>(() => {
if (!day) return []
// The roads driven, not the straight lines between stops. A corridor built from the
// stops searches beside a line the car never takes — between two cities that is open
// country, which is why everything it found sat at the stops themselves.
if (day.geometry.length > 1) return day.geometry.map(([lat, lng]) => ({ lat, lng }))
// Until the day has routed there is nothing else to go on.
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
}, [day])
const search = useCorridorPois(line, categories, widthKm)
@@ -31,6 +31,13 @@ export interface RoadtripDay {
legs: (RouteSegment | undefined)[]
/** Arrival and departure per stop, walked forward from the day's first pinned time. */
schedule: Schedule
/**
* The roads actually driven that day, as [lat, lng] — not the straight lines between
* stops. Anything asking "what is along this day" has to use this: between Hamburg and
* Berlin the straight line runs across open country while the motorway swings north of
* it, so a corridor built from the stops finds the wrong side of the map.
*/
geometry: [number, number][]
distance: number
duration: number
}
@@ -136,6 +143,7 @@ export function useRoadtripRoutes(
stops,
legs: [],
schedule: { entries: [], warnings: [] },
geometry: [],
distance: 0,
duration: 0,
}
@@ -244,6 +252,9 @@ export function useRoadtripRoutes(
if (leg.line.length > 1) lines.push(leg.line)
segments.push(leg.seg)
}
// The run polylines end to end: leg 0 of a run carries its whole geometry, the
// rest carry none, so concatenating what is there gives the day as driven.
const geometry = routed.flatMap(l => l?.line ?? [])
const legs = routed.map(l => l?.seg)
const distance = legs.reduce((sum, l) => sum + (l?.distance ?? 0), 0)
const duration = legs.reduce((sum, l) => sum + (l?.duration ?? 0), 0)
@@ -251,7 +262,7 @@ export function useRoadtripRoutes(
day.stops.map(s => ({ anchor: s.time, dwellMinutes: s.dwellMinutes })),
legs.map(l => l?.duration),
)
return { ...day, legs, schedule, distance, duration }
return { ...day, legs, schedule, geometry, distance, duration }
})
return {
days: out,