Merge pull request #2330 from liketrek/feat/pdf-trip-route-map

Feat/pdf trip route map
This commit is contained in:
Konstantinos Thermos
2026-09-16 16:10:31 +03:00
committed by GitHub
35 changed files with 1161 additions and 173 deletions
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { dayColor } from '../Roadtrip/dayColors'
import { dayRouteColor, planTripRoute, routeTrip, summariseTripRoute } from './tripRouteGeometry'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { AssignmentsMap, RouteSegment } from '../../types'
vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>()
return { ...actual, calculateRouteWithLegs: vi.fn() }
})
const { calculateRouteWithLegs } = await import('./RouteCalculator')
const leg = (distance: number): RouteSegment => ({
mid: [0, 0], from: [0, 0], to: [0, 0], distance, duration: 600,
distanceText: '10 km', durationText: '10 min', walkingText: '2 h', drivingText: '10 min',
})
/** A router that never answers but honours its signal, exactly as a real fetch does —
* without that the abort has nothing to cut and the pool waits forever. */
const neverAnswers = () => vi.mocked(calculateRouteWithLegs).mockImplementation(
(_waypoints, opts) => new Promise((_resolve, reject) => {
opts?.signal?.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}) as ReturnType<typeof calculateRouteWithLegs>,
)
const at = (lat: number, lng: number, order: number, dayId: number) =>
buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }) })
const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })]
const ASSIGNMENTS: AssignmentsMap = {
'1': [at(48.86, 2.35, 0, 1), at(48.90, 2.42, 1, 1)],
'2': [at(45.76, 4.83, 0, 2), at(45.80, 4.90, 1, 2)],
}
const input = { days: DAYS, assignments: ASSIGNMENTS, reservations: [], accommodations: [], optimizeFromAccommodation: false }
beforeEach(() => {
vi.mocked(calculateRouteWithLegs).mockReset()
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: [[48.86, 2.35], [48.90, 2.42]],
distance: 10000, duration: 600,
legs: [leg(10000)],
})
})
describe('tripRouteGeometry', () => {
it('FE-MAP-TRG-001: plans one request per day and skips days with nothing to drive', () => {
const plan = planTripRoute({ ...input, assignments: { ...ASSIGNMENTS, '2': [at(45.76, 4.83, 0, 2)] } }, 'driving')
expect(plan).toHaveLength(1)
expect(plan[0].day.id).toBe(1)
expect(plan[0].runs[0]).toHaveLength(1)
})
it('FE-MAP-TRG-002: a day is drawn in the colour the road trip gives it', () => {
// One palette for both readings of the trip, so a day never changes colour.
expect(dayRouteColor(buildDay({ day_number: 1 }))).toEqual(dayColor(1))
expect(dayRouteColor(buildDay({ day_number: 2 }))).toEqual(dayColor(2))
})
it('FE-MAP-TRG-003: routes every day and sums the trip', async () => {
const summary = await routeTrip(input, { profile: 'driving', tripId: 7 })
expect(summary.days).toHaveLength(2)
expect(summary.totalDistance).toBe(20000)
expect(summary.lines).toHaveLength(2)
expect(summary.lineColors).toHaveLength(2)
})
it('FE-MAP-TRG-004: an empty trip summarises to nothing rather than throwing', async () => {
const summary = await routeTrip({ ...input, assignments: {} }, { profile: 'driving', tripId: 7 })
expect(summary).toEqual(summariseTripRoute([]))
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('FE-MAP-TRG-005: the deadline gives back what answered and leaves the rest as straight lines', async () => {
neverAnswers()
vi.useFakeTimers()
try {
const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 8000 })
await vi.advanceTimersByTimeAsync(8100)
const summary = await pending
// The shape of the trip survives...
expect(summary.lines).toHaveLength(2)
expect(summary.lines[0]).toEqual([[48.86, 2.35], [48.90, 2.42]])
// ...and nothing that never answered is counted.
expect(summary.totalDistance).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('FE-MAP-TRG-006: a caller\'s own abort cuts the routing short too', async () => {
neverAnswers()
const controller = new AbortController()
const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 60_000, signal: controller.signal })
controller.abort()
await expect(pending).resolves.toMatchObject({ totalDistance: 0 })
})
})
@@ -0,0 +1,215 @@
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
import { resolveLegMode } from '../Planner/legMode'
import { dayColor } from '../Roadtrip/dayColors'
import type { Day, 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][][]
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[]
}
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
}
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
* exactly as the single-day route builds them. */
interface Chunk { points: DayRoutePoint[]; mode: string }
/** A day reduced to the routing requests it needs, in the order they draw. */
export interface TripRoutePlanDay { day: Day; runs: Chunk[][] }
type Answer = { coordinates: [number, number][]; legs: RouteSegment[] } | null
/** 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): 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++
chunks.push({ points: run.slice(i, end + 1), mode })
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: DayRouteInputs, profile: RouteProfileKey): TripRoutePlanDay[] {
return [...input.days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(day => ({ day, runs: buildDayRouteRuns(day.id, input) }))
.filter(entry => entry.runs.length > 0)
.map(({ day, runs }) => ({
day,
runs: runs.map(run => chunkRun(run, day.default_transport_mode || profile)),
}))
}
/** 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 }, d) => {
const lines: [number, number][][] = []
const segments: RouteSegment[] = []
runs.forEach((chunks, r) => {
const polyline: [number, number][] = []
chunks.forEach((chunk, c) => {
const answer = routed[d]?.[r]?.[c]
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,
segments,
distance: segments.reduce((sum, s) => sum + s.distance, 0),
duration: segments.reduce((sum, s) => sum + s.duration, 0),
modes,
}
})
}
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(),
totalDistance: days.reduce((sum, d) => sum + d.distance, 0),
totalDuration: days.reduce((sum, d) => sum + d.duration, 0),
}
}
/**
* Ask the router for every leg of every day, a few at a time.
*
* A fortnight is dozens of legs and the routing host is usually a shared OSRM, so the
* pool is deliberately small — the same one the sidebar connectors use. RouteCalculator's
* cache is shared with the day route, so a day already drawn on screen costs nothing.
*
* A leg the router refuses keeps its straight line and contributes no distance, exactly
* as a failed leg of a day route does. Nothing here throws.
*/
export async function routeTripLegs(
plan: TripRoutePlanDay[],
{ tripId, signal, onAnswer }: {
tripId: number | null
signal?: AbortSignal
/** 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 () => {
try {
const answer = await calculateRouteWithLegs(
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
onAnswer?.(routed)
} catch {
// Refused (usually a rate limit) — the straight line stands.
}
})
})
})
})
let next = 0
const worker = async () => {
while (next < tasks.length && !signal?.aborted) await tasks[next++]()
}
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
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: DayRouteInputs,
{ profile, tripId, signal, timeoutMs }: {
profile: RouteProfileKey
tripId: number | null
signal?: AbortSignal
timeoutMs?: number
},
): 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 })
return summariseTripRoute(assembleTripRoute(plan, routed))
} finally {
if (timer) clearTimeout(timer)
}
}
+25 -169
View File
@@ -1,68 +1,20 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
import { resolveLegMode } from '../Planner/legMode'
import { type RouteProfileKey } from './RouteCalculator'
import {
assembleTripRoute, emptyAnswers, planTripRoute, routeTripLegs, summariseTripRoute,
type TripRouteSummary,
} from './tripRouteGeometry'
import { useSettingsStore } from '../../store/settingsStore'
import { dayColor } from '../Roadtrip/dayColors'
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
import type { Accommodation, AssignmentsMap, Day, Reservation } 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][][]
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[]
}
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
export interface TripRouteOverview {
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 the map can frame the whole trip at once. */
focusPoints: [number, number][]
totalDistance: number
totalDuration: number
export interface TripRouteOverview extends TripRouteSummary {
/** True until every leg has answered — the totals are a partial sum until then. */
loading: boolean
}
const EMPTY: TripRouteOverview = {
days: [], lines: [], lineColors: [], segments: [], focusPoints: [],
totalDistance: 0, totalDuration: 0, loading: false,
}
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
* exactly as the single-day route builds them. */
interface Chunk { points: DayRoutePoint[]; mode: string }
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): 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++
chunks.push({ points: run.slice(i, end + 1), mode })
i = end
}
return chunks
}
const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng])
const EMPTY: TripRouteOverview = { ...summariseTripRoute([]), loading: false }
/**
* Every travel day's route at once, each day in its own colour, with the trip's total
@@ -70,11 +22,9 @@ const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p =
*
* The geometry is the day view's own — `buildDayRouteRuns` is the same builder
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
* you are looking at it alone or at the whole trip. Only the aggregation is new.
*
* Legs go out a few at a time rather than all at once: a fortnight is dozens of legs and
* the routing host is usually a shared OSRM. RouteCalculator's cache is shared with the
* day route and the sidebar connectors, so the day already on screen costs nothing.
* you are looking at it alone or at the whole trip. This hook is the React wrapper:
* the planning and routing themselves live in `tripRouteGeometry`, so the PDF export
* can draw the same trip without a component to hang a hook on.
*/
export function useTripRouteOverview(
tripId: number | null,
@@ -91,22 +41,18 @@ export function useTripRouteOverview(
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
const abortRef = useRef<AbortController | null>(null)
const plan = useMemo(() => {
if (!enabled) return []
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(day => ({
day,
runs: buildDayRouteRuns(day.id, { days, assignments, reservations, accommodations, optimizeFromAccommodation }),
}))
.filter(entry => entry.runs.length > 0)
}, [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation])
const plan = useMemo(
() => (enabled
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation }, profile)
: []),
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile],
)
// Only geometry and mode decide whether legs have to be fetched again: renaming a
// place or editing its notes must not fire a routing round.
const planKey = useMemo(
() => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
.map(run => run.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)},${p.leg_transport_mode ?? ''},${p.incoming_leg_transport_mode ?? ''}`).join('|'))
.map(chunks => chunks.map(c => `${c.mode}>${c.points.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|')}`).join('+'))
.join('/')}`).join(';'),
[plan],
)
@@ -118,110 +64,20 @@ export function useTripRouteOverview(
const controller = new AbortController()
abortRef.current = controller
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.
const chunked = plan.map(({ day, runs }) => {
const dayDefaultMode = day.default_transport_mode || profile
return { day, runs: runs.map(run => chunkRun(run, dayDefaultMode)) }
})
const assemble = (
routed: (({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)[][][],
): TripRouteOverview => {
const overviewDays: TripOverviewDay[] = chunked.map(({ day, runs }, d) => {
const lines: [number, number][][] = []
const segments: RouteSegment[] = []
runs.forEach((chunks, r) => {
const polyline: [number, number][] = []
chunks.forEach((chunk, c) => {
const answer = routed[d][r][c]
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: colorFor(day),
lines,
segments,
distance: segments.reduce((sum, s) => sum + s.distance, 0),
duration: segments.reduce((sum, s) => sum + s.duration, 0),
modes,
}
})
const lines = overviewDays.flatMap(d => d.lines)
return {
days: overviewDays,
lines,
lineColors: overviewDays.flatMap(d => d.lines.map(() => d.color)),
segments: overviewDays.flatMap(d => d.segments),
focusPoints: lines.flat(),
totalDistance: overviewDays.reduce((sum, d) => sum + d.distance, 0),
totalDuration: overviewDays.reduce((sum, d) => sum + d.duration, 0),
loading: false,
}
}
const routed = chunked.map(({ runs }) => runs.map(chunks => chunks.map(() => null as
({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)))
// Straight lines first so the shape of the trip is on screen immediately, then the
// real roads replace them — the same two-step the single-day route draws with.
setResult({ ...assemble(routed), loading: true })
setResult({ ...summariseTripRoute(assembleTripRoute(plan, emptyAnswers(plan))), loading: true })
;(async () => {
const tasks: (() => Promise<void>)[] = []
chunked.forEach(({ day, runs }, d) => {
runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => {
tasks.push(async () => {
try {
const answer = await calculateRouteWithLegs(
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ signal: controller.signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
} catch {
// Routing refused this leg (usually a rate limit) — it keeps its
// straight line and contributes no distance, exactly as a day route's
// failed leg does.
}
})
})
})
})
// Small pool on purpose, the same one the sidebar connectors use: a fortnight is
// dozens of legs and the routing host is often a shared OSRM.
let next = 0
const worker = async () => {
while (next < tasks.length && !controller.signal.aborted) await tasks[next++]()
}
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
if (!controller.signal.aborted) setResult(assemble(routed))
})()
routeTripLegs(plan, { tripId, signal: controller.signal }).then(routed => {
if (controller.signal.aborted) return
setResult({ ...summariseTripRoute(assembleTripRoute(plan, routed)), loading: false })
})
return () => controller.abort()
// planKey is derived from the same inputs as plan, so keying on the string is
// equivalent while staying stable across unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [planKey, profile, tripId, distanceUnit])
}, [planKey, tripId, distanceUnit])
return result
}