Files
TREK/client/src/components/Map/tripRouteGeometry.ts
T
Maurice a74a2cf9b5 fix(map): the day route waits out a busy router instead of keeping straight lines
The public OSRM hosts answer 429 above about one request a second. The trip
overview and the road trip waited that out and asked again; the day route
took the first refusal as final and drew straight lines between the stops
until the next edit, which is what a day with a change of mode or a quick
switch between days ran into. Both the day route and the overview now go
through one retry helper: a rate limit is waited out, as long as the host
asks when it says, and a refusal the host meant still ends at once.
2026-10-18 13:21:52 +02:00

279 lines
12 KiB
TypeScript

import type { RouteProfileKey } from './RouteCalculator'
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
import { projectDayItinerary } from './dayTourProjection'
import { resolveLegMode } from '../Planner/legMode'
import { dayColor } from '../Roadtrip/dayColors'
import type { RoadtripVia } from '@trek/shared'
import type { AssignmentsMap, Day, DistanceUnit, RouteSegment } from '../../types'
/** One travel day of the overview: the roads it covers, in its own colour. */
export interface TripOverviewDay {
dayId: number
dayNumber: number
date: string | null
title: string | null
/** 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][][]
/** Saved Tours are not routed legs and keep their own map style. */
tourLines?: [number, number][][]
segments: RouteSegment[]
/** Metres and seconds, summed over the day's legs. */
distance: number
duration: number
/** The modes this day is actually travelled in, first use first. */
modes: string[]
/**
* Legs drawn as a straight line because the router gave no road for them: not asked
* yet while the round is running, refused or cut off by the deadline once it is over.
* Those legs add nothing to `distance`, so a day with any of them reads too short.
*/
unroutedLegs?: number
}
export interface TripRouteSummary {
days: TripOverviewDay[]
/** 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: { line: string; casing: string }[]
segments: RouteSegment[]
/** Every drawn coordinate, so a map can frame the whole trip at once. */
focusPoints: [number, number][]
totalDistance: number
totalDuration: number
/** `unroutedLegs` over every day: above zero, `totalDistance` is a partial sum. */
unroutedLegs?: number
}
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
* exactly as the single-day route builds them. `waypoints` are the points with the road
* trip's vias threaded in, and `stopAt` says which waypoint each point became. */
interface Chunk { points: DayRoutePoint[]; mode: string; waypoints: { lat: number; lng: number }[]; stopAt: number[] }
/** The day inputs plus the road trip's vias, which bend a day the way it was picked there (#2580). */
interface TripRouteInputs extends DayRouteInputs {
/** The stored day lists the vias are anchored in, hidden rows included. Absent: `assignments`. */
storedAssignments?: AssignmentsMap
viasByDay?: Record<number, readonly RoadtripVia[]>
}
/** A day reduced to the routing requests it needs, in the order they draw. */
export interface TripRoutePlanDay { day: Day; runs: Chunk[][]; tourLines?: [number, number][][] }
type Answer = { coordinates: [number, number][]; legs: RouteSegment[] } | null
/** Every leg's answer so far, indexed like the plan: day, run, chunk. */
export type TripRouteAnswers = Answer[][][]
/** The palette entry a day keeps, whatever else is added to the trip around it. */
export const dayRouteColor = (day: Day): { line: string; casing: string } =>
dayColor(day.day_number ?? 0)
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string, dayId: number, viaLookup: DayViaLookup | null): Chunk[] {
const chunks: Chunk[] = []
let i = 0
while (i < run.length - 1) {
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
let end = i + 1
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
const points = run.slice(i, end + 1)
const { waypoints, stopAt } = threadVias(points, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to))
chunks.push({ points, mode, waypoints, stopAt })
i = end
}
return chunks
}
const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng])
/**
* Every travel day of the trip reduced to the routing requests it needs.
*
* Pure and synchronous, so a caller can compare two plans (the map keys its routing
* round on one) before spending a single request.
*/
export function planTripRoute(input: TripRouteInputs, profile: RouteProfileKey): TripRoutePlanDay[] {
const viaLookup = dayViaLookup(input.storedAssignments ?? input.assignments, input.viasByDay)
return [...input.days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(day => ({
day,
runs: buildDayRouteRuns(day.id, input),
tourLines: projectDayItinerary(
(input.assignments[String(day.id)] ?? []).filter(assignment => assignment.accommodation_id == null),
input.toursEnabled ?? false,
input.places,
).flatMap(item => item.kind === 'tour' && item.geometry.length >= 2 ? [item.geometry] : []),
}))
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
.map(({ day, runs, tourLines }) => ({
day,
runs: runs.map(run => chunkRun(run, day.default_transport_mode || profile, day.id, viaLookup)),
tourLines,
}))
}
/** What a plan looks like before any leg has answered: straight lines, no distances. */
export const emptyAnswers = (plan: TripRoutePlanDay[]): Answer[][][] =>
plan.map(({ runs }) => runs.map(chunks => chunks.map(() => null)))
/** Stitch the routed answers back onto the plan, in the order the days are travelled. */
export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]): TripOverviewDay[] {
return plan.map(({ day, runs, tourLines }, d) => {
const lines: [number, number][][] = []
const segments: RouteSegment[] = []
let unroutedLegs = 0
runs.forEach((chunks, r) => {
const polyline: [number, number][] = []
chunks.forEach((chunk, c) => {
const answer = routed[d]?.[r]?.[c]
if (!answer) unroutedLegs++
const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points)
for (const point of coords) {
// Drop the point shared with the previous chunk so concatenated legs
// don't leave a duplicate at each junction.
const last = polyline[polyline.length - 1]
if (last && last[0] === point[0] && last[1] === point[1]) continue
polyline.push(point)
}
if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode })
})
if (polyline.length >= 2) lines.push(polyline)
})
const modes: string[] = []
for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode)
return {
dayId: day.id,
dayNumber: day.day_number ?? 0,
date: day.date ?? null,
title: day.title ?? null,
color: dayRouteColor(day),
lines,
tourLines,
segments,
distance: segments.reduce((sum, s) => sum + s.distance, 0),
duration: segments.reduce((sum, s) => sum + s.duration, 0),
modes,
unroutedLegs,
}
})
}
export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
const lines = days.flatMap(d => d.lines)
return {
days,
lines,
lineColors: days.flatMap(d => d.lines.map(() => d.color)),
segments: days.flatMap(d => d.segments),
focusPoints: [...lines.flat(), ...days.flatMap(day => (day.tourLines ?? []).flat())],
totalDistance: days.reduce((sum, d) => sum + d.distance, 0),
totalDuration: days.reduce((sum, d) => sum + d.duration, 0),
unroutedLegs: days.reduce((sum, d) => sum + (d.unroutedLegs ?? 0), 0),
}
}
/**
* Gap between two routing requests. The public OSRM hosts TREK ships with state one
* request per second; the overview asks for every leg of every day, so without spacing
* the first handful answer and the rest come back 429 (the road trip rail learnt this
* first and paces itself the same way).
*/
const REQUEST_SPACING_MS = 1100
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
const CACHE_HIT_MS = 60
/**
* Ask the router for every leg of every day, one at a time.
*
* A fortnight is dozens of legs and the routing host is usually a shared OSRM that
* refuses a burst, so the legs go out one after another with a pause between them, and
* a rate limit is waited out (for as long as the host asks, when it says) and asked
* again. Only what actually went to the network is paced: RouteCalculator's cache is
* shared with the day route, so a day already drawn on screen costs nothing and waits
* for nothing.
*
* A leg the router still refuses keeps its straight line and contributes no distance,
* exactly as a failed leg of a day route does; `assembleTripRoute` counts it so the
* caller can say the total is short. A refusal the host meant (these coordinates, this
* profile) is not repeated, because the same request earns the same answer. Nothing
* here throws.
*/
export async function routeTripLegs(
plan: TripRoutePlanDay[],
{ tripId, signal, onAnswer, unit }: {
tripId: number | null
signal?: AbortSignal
/** The unit a leg bent through vias has its text written again in. */
unit?: DistanceUnit
/** Called after each answer, for a caller that wants to draw as they arrive. */
onAnswer?: (routed: Answer[][][]) => void
},
): Promise<Answer[][][]> {
const routed = emptyAnswers(plan)
const tasks: (() => Promise<void>)[] = []
plan.forEach(({ day, runs }, d) => {
runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => {
tasks.push(async () => {
if (signal?.aborted) return
try {
const answer = await calculateRouteWithRetries(
chunk.waypoints,
{ signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
onAnswer?.(routed)
} catch {
// Refused for good, or aborted: the leg keeps its straight line.
}
})
})
})
})
for (let i = 0; i < tasks.length; i++) {
if (signal?.aborted) break
const startedAt = performance.now()
await tasks[i]()
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, signal)
}
return routed
}
/**
* The whole thing end to end, for a caller with no need to draw the intermediate state.
*
* `timeoutMs` stops waiting rather than stopping the work: legs that answered are kept
* and the rest stay straight lines, which is what makes this usable from an export
* somebody is standing in front of. A month-long trip with a cold cache is a few hundred
* requests against a shared router, and no document is worth that wait.
*/
export async function routeTrip(
input: TripRouteInputs,
{ profile, tripId, signal, timeoutMs, unit }: {
profile: RouteProfileKey
tripId: number | null
signal?: AbortSignal
timeoutMs?: number
unit?: DistanceUnit
},
): Promise<TripRouteSummary> {
const plan = planTripRoute(input, profile)
if (!plan.length) return summariseTripRoute([])
const deadline = timeoutMs ? new AbortController() : null
const timer = deadline ? setTimeout(() => deadline.abort(), timeoutMs) : null
if (deadline && signal) signal.addEventListener('abort', () => deadline.abort(), { once: true })
try {
const routed = await routeTripLegs(plan, { tripId, signal: deadline?.signal ?? signal, unit })
return summariseTripRoute(assembleTripRoute(plan, routed))
} finally {
if (timer) clearTimeout(timer)
}
}