mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
503 lines
24 KiB
TypeScript
503 lines
24 KiB
TypeScript
import { assembleRoadtrip, foldRouteRun, type RoadtripStop, type RoadtripRoutes, type PlanDay, type QuietDay, type RoutedLeg } from '@trek/shared/roadtrip'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator'
|
|
import { resolveLegMode } from '../Planner/legMode'
|
|
import { splitIntoRuns, parseAvoid, type DriveLimits } from './roadtripModel'
|
|
import { spillChains } from './nightSpill'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { useVehicleRange } from './useVehicleRange'
|
|
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, SnappedWaypoint } from '../../types'
|
|
import type { RoadtripVia, RoadtripDayBoundary } from '@trek/shared'
|
|
import { dayWindow } from './dayWindow'
|
|
import { useTranslation } from '../../i18n/TranslationContext'
|
|
|
|
export type { RoadtripStop, RoadtripDay, RoadtripRoutes, QuietDay, AccessSpur, RoutedLeg, SnappedPoint } from '@trek/shared/roadtrip'
|
|
|
|
/**
|
|
* Gap between two routing requests. The public OSRM hosts TREK ships with state one
|
|
* request per second; a road trip asks for every leg of every day, so without spacing
|
|
* the first handful answer and the rest come back 429.
|
|
*/
|
|
const REQUEST_SPACING_MS = 1100
|
|
/** Anything answered faster than this came out of RouteCalculator cache, not the network. */
|
|
const CACHE_HIT_MS = 60
|
|
/** How often a leg that failed (usually a rate limit) is tried again, and how long after. */
|
|
const RETRY_DELAYS_MS = [1500, 4000]
|
|
|
|
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
|
|
new Promise(resolve => {
|
|
if (signal.aborted) { resolve(); return }
|
|
const timer = setTimeout(done, ms)
|
|
function done(): void {
|
|
clearTimeout(timer)
|
|
signal.removeEventListener('abort', done)
|
|
resolve()
|
|
}
|
|
signal.addEventListener('abort', done, { once: true })
|
|
})
|
|
|
|
/**
|
|
* What makes a stop the same stop as far as routing is concerned. `planKey` is built from
|
|
* these, and so are the keys the routed legs are filed under, so the two can never drift
|
|
* apart.
|
|
*
|
|
* Deliberately not the assignment id. A stop added mid-day is written optimistically with
|
|
* a temporary negative id (`assignmentsSlice`) and swapped for the real one once the
|
|
* server answers, without its coordinates changing — so `planKey` stays identical, the
|
|
* effect does not run again, and a leg filed under the id would sit under a dead one
|
|
* forever. A missing leg breaks the schedule's chain (`computeSchedule` gives up its
|
|
* cursor), which would silently blank every arrival time after the new stop.
|
|
*/
|
|
const stopKey = (s: RoadtripStop): string =>
|
|
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
|
|
|
|
/**
|
|
* The via points that shape the drive leaving a stop, as one comparable string.
|
|
*
|
|
* Read in two places that must agree: when deciding whether a seam still matches the
|
|
* answer already in hand, and when recording what an answer was fetched for. One function
|
|
* so the two cannot drift and quietly stop refetching.
|
|
*/
|
|
const seamShape = (from: RoadtripStop, viasByDay: Record<number, RoadtripVia[]>): string =>
|
|
(viasByDay[from.ownerDayId] ?? [])
|
|
.filter(v => v.after_order_index === from.ownerIndex)
|
|
.sort((a, b) => a.sequence - b.sequence)
|
|
.map(v => `${v.lat.toFixed(5)},${v.lng.toFixed(5)}`)
|
|
.join('|')
|
|
|
|
/** The drive from one stop to the next, identified the same way `planKey` identifies them. */
|
|
const legKey = (from: RoadtripStop, to: RoadtripStop): string => `${stopKey(from)}>${stopKey(to)}`
|
|
|
|
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number): RoadtripStop | null => {
|
|
const p = a.place
|
|
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
|
|
return {
|
|
assignmentId: a.id,
|
|
ownerDayId,
|
|
ownerIndex,
|
|
placeId: a.place_id,
|
|
name: p.name,
|
|
lat: p.lat,
|
|
lng: p.lng,
|
|
time: a.assignment_time ?? p.place_time ?? null,
|
|
dwellMinutes: typeof p.duration_minutes === 'number' ? p.duration_minutes : null,
|
|
endDay: a.end_day === true,
|
|
legMode: a.leg_transport_mode ?? null,
|
|
incomingLegMode: a.incoming_leg_transport_mode ?? null,
|
|
stopType: p.stop_type ?? null,
|
|
fillPercent: typeof p.fill_percent === 'number' ? p.fill_percent : null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Distance and driving time for every leg of every day of the trip.
|
|
*
|
|
* The day plan sidebar routes one day at a time because that is all it shows; a road
|
|
* trip is the whole chain, which is exactly what #435 asks for.
|
|
*
|
|
* A day is one request, not one per leg: the router already returns a leg for every
|
|
* consecutive pair of the waypoints it is handed. Requests go out one after another
|
|
* with a gap, because the public routing hosts TREK ships with allow about one per
|
|
* second — but only the ones that actually reach the network are paced, so returning
|
|
* to this view costs nothing.
|
|
*/
|
|
export function useRoadtripRoutes(
|
|
tripId: number | string | null,
|
|
days: Day[],
|
|
assignments: AssignmentsMap,
|
|
/** Mode for legs that neither the stop nor the day pins down. */
|
|
fallbackProfile: string = 'driving',
|
|
/**
|
|
* Points the drive is made to pass through, per day (#1797). They join the routing
|
|
* request between the stops they follow, so the router draws the road the traveller
|
|
* chose rather than the one it prefers.
|
|
*/
|
|
viasByDay: Record<number, RoadtripVia[]> = {},
|
|
boundaries: RoadtripDayBoundary[] = [],
|
|
): RoadtripRoutes {
|
|
const { t } = useTranslation()
|
|
const routeProfile = fallbackProfile || 'driving'
|
|
// Leg text is pre-formatted in the chosen unit, so a km↔mi switch has to re-fetch.
|
|
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
|
const [legsByDay, setLegsByDay] = useState<Record<number, Record<string, RoutedLeg>>>({})
|
|
/**
|
|
* Where the router put each stop, by day and stop key.
|
|
*
|
|
* Kept beside the legs rather than inside them because it belongs to a stop and a leg
|
|
* has two of them: the arrival end of one leg is the departure end of the next, and
|
|
* filing it twice would draw the spur twice.
|
|
*/
|
|
const [snapByDay, setSnapByDay] = useState<Record<number, Record<string, SnappedWaypoint>>>({})
|
|
/** Per day, the classes it was asked to avoid and did not get. See RoadtripDay.avoidMissed. */
|
|
const [missedByDay, setMissedByDay] = useState<Record<number, RouteAvoidClass[]>>({})
|
|
/**
|
|
* Drives that only exist because a night moved a stop onto the next day's card.
|
|
*
|
|
* The trip is routed a stored day at a time, so the road between the last stop of one
|
|
* day and the first of the next was never asked for — there was no chain that needed
|
|
* it. Once a drive across midnight hands its stops forward, the stop it ends at and the
|
|
* next day's own first stop become NEIGHBOURS in one chain, and a chain with a hole in
|
|
* it draws two disconnected runs on the map. Filed by leg key like every other leg, so
|
|
* everything downstream reads them without knowing where they came from.
|
|
*/
|
|
const [seamLegs, setSeamLegs] = useState<Record<string, RoutedLeg & { shape: string }>>({})
|
|
const [loading, setLoading] = useState(false)
|
|
const abortRef = useRef<AbortController | null>(null)
|
|
|
|
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
|
|
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
|
|
const fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent)
|
|
/**
|
|
* Road classes to weight away, as the settings row stores them: a comma list.
|
|
*
|
|
* Parsed against the known classes rather than trusted, because there is no
|
|
* server-side validation for a per-user setting — the write route stores any key with
|
|
* any value — and an unknown word here would become a costing option the router does
|
|
* not have.
|
|
*/
|
|
const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid)
|
|
/**
|
|
* Whether the gaps between days are driven too.
|
|
*
|
|
* A trip is stored as days and routed as days, so the road from one day's last stop to
|
|
* the next day's first was never asked for: no chain needed it. Turning this on makes
|
|
* the trip one continuous drive — every one of those gaps is routed, drawn, and counted
|
|
* towards the day it ARRIVES on, which is the same rule a night drive already follows.
|
|
*/
|
|
const connectSetting = useSettingsStore(s => !!s.settings.roadtrip_connect_days)
|
|
const startTime = useSettingsStore(s => s.settings.roadtrip_day_start)
|
|
const endTime = useSettingsStore(s => s.settings.roadtrip_day_end)
|
|
const endMode = useSettingsStore(s => s.settings.roadtrip_day_end_mode)
|
|
const window = useMemo(() => dayWindow(startTime, endTime, endMode), [startTime, endTime, endMode])
|
|
const connectDays = connectSetting || window !== null
|
|
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
|
|
// What the car is and how far it goes on one fill, assembled in one place because the
|
|
// rail needs the same answer to say what a given fill buys at a given stop.
|
|
const { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange()
|
|
const avoidKey = avoid.join(',')
|
|
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
|
|
// same thing, so it is folded here rather than guarded at every reading.
|
|
const limits = useMemo<DriveLimits>(
|
|
() => ({
|
|
legMinutes: legMinutes || null,
|
|
dayMinutes: dayMinutes || null,
|
|
rangeKm: planningRangeKm,
|
|
// Only a real fraction counts. Zero, absent and 100 all mean "fills right up",
|
|
// which is what the budget did before the setting existed.
|
|
fillPercent: fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : null,
|
|
}),
|
|
[legMinutes, dayMinutes, planningRangeKm, fillPercent],
|
|
)
|
|
|
|
const plan = useMemo<PlanDay[]>(() => {
|
|
return [...days]
|
|
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
|
.map(d => {
|
|
const stops = (assignments[String(d.id)] ?? [])
|
|
.slice()
|
|
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
|
.map(a => asStop(a, d.id, 0))
|
|
.filter((s): s is RoadtripStop => s !== null)
|
|
// The index is filled in after the drop, because it is the index into THIS
|
|
// list: an assignment whose place has no coordinates never becomes a stop, and
|
|
// counting before the filter would name a row the rail does not draw.
|
|
.map((s, i) => ({ ...s, ownerIndex: i }))
|
|
return {
|
|
dayId: d.id,
|
|
dayNumber: d.day_number ?? 0,
|
|
date: d.date ?? null,
|
|
title: d.title ?? null,
|
|
stops,
|
|
}
|
|
})
|
|
.filter(d => d.stops.length > 1)
|
|
}, [days, assignments])
|
|
|
|
const quietDays = useMemo<QuietDay[]>(() => {
|
|
return [...days]
|
|
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
|
.map(d => ({
|
|
dayId: d.id,
|
|
dayNumber: d.day_number ?? 0,
|
|
date: d.date ?? null,
|
|
title: d.title ?? null,
|
|
stops: (assignments[String(d.id)] ?? [])
|
|
.slice()
|
|
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
|
.map(a => asStop(a, d.id, 0))
|
|
.filter((s): s is RoadtripStop => s !== null)
|
|
.map((s, i) => ({ ...s, ownerIndex: i })),
|
|
}))
|
|
.filter(d => d.stops.length < 2)
|
|
}, [days, assignments])
|
|
|
|
// Only the geometry decides whether legs have to be re-fetched: renaming a place or
|
|
// editing its notes must not fire a routing round.
|
|
const viaKey = useMemo(
|
|
() => Object.entries(viasByDay)
|
|
.map(([dayId, vias]) => `${dayId}:${vias.map(v => `${v.after_order_index}@${v.lat.toFixed(5)},${v.lng.toFixed(5)}`).join('|')}`)
|
|
// Only the order has to be stable — this is a cache key, not a list anybody reads
|
|
// — but it has to be stable on purpose rather than by default.
|
|
.sort((a, b) => a.localeCompare(b))
|
|
.join(';'),
|
|
[viasByDay],
|
|
)
|
|
|
|
const planKey = useMemo(
|
|
() => `${plan.map(d => `${d.dayId}:${d.stops.map(stopKey).join('|')}`).join(';')}#${viaKey}`,
|
|
[plan, viaKey],
|
|
)
|
|
|
|
useEffect(() => {
|
|
abortRef.current?.abort()
|
|
if (!plan.length) {
|
|
setLegsByDay({})
|
|
setSnapByDay({})
|
|
setMissedByDay({})
|
|
setLoading(false)
|
|
return
|
|
}
|
|
const controller = new AbortController()
|
|
abortRef.current = controller
|
|
setLoading(true)
|
|
|
|
const dayDefault = (dayId: number): string =>
|
|
days.find(d => d.id === dayId)?.default_transport_mode || routeProfile
|
|
|
|
const collected: Record<number, Record<string, RoutedLeg>> = {}
|
|
const collectedSnaps: Record<number, Record<string, SnappedWaypoint>> = {}
|
|
const collectedMisses: Record<number, RouteAvoidClass[]> = {}
|
|
const tasks: (() => Promise<void>)[] = []
|
|
|
|
for (const day of plan) {
|
|
const dayLegs: Record<string, RoutedLeg> = {}
|
|
collected[day.dayId] = dayLegs
|
|
const dfMode = dayDefault(day.dayId)
|
|
|
|
const runs = splitIntoRuns(day.stops, (from, to) =>
|
|
resolveLegMode(
|
|
{ isPlace: true, leg_transport_mode: from.legMode },
|
|
{ isPlace: true, incoming_leg_transport_mode: to.incomingLegMode },
|
|
dfMode,
|
|
))
|
|
|
|
|
|
for (const { stops: run, mode } of runs) {
|
|
tasks.push(async () => {
|
|
// Waypoints are the stops with this day's vias threaded in between them, so the
|
|
// router draws the road the traveller picked. `stopAt` remembers which waypoint
|
|
// each stop became, because the answer has a leg per waypoint PAIR and the rail
|
|
// wants one leg per stop pair.
|
|
const waypoints: { lat: number; lng: number }[] = []
|
|
const stopAt: number[] = []
|
|
run.forEach((stop, i) => {
|
|
stopAt.push(waypoints.length)
|
|
waypoints.push({ lat: stop.lat, lng: stop.lng })
|
|
if (i === run.length - 1) return
|
|
// A via is filed against the day it was dropped on and the position it sits
|
|
// after IN THAT DAY. Since a chain can hold stops from an earlier day (see
|
|
// `nightSpill.ts`), the lookup has to be the stop's own day and its own
|
|
// index — this used to be the position within the chain, so on any day that
|
|
// received a night drive every via matched nothing and quietly stopped
|
|
// shaping the road.
|
|
;(viasByDay[stop.ownerDayId ?? day.dayId] ?? [])
|
|
.filter(v => v.after_order_index === (stop.ownerIndex ?? i))
|
|
.sort((a, b) => a.sequence - b.sequence)
|
|
.forEach(v => waypoints.push({ lat: v.lat, lng: v.lng }))
|
|
})
|
|
|
|
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
if (controller.signal.aborted) return
|
|
try {
|
|
const r = await calculateRouteWithLegs(
|
|
waypoints,
|
|
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: day.dayId, avoid },
|
|
)
|
|
// Where each stop ended up. stopAt[i] is that stop's waypoint index, so the
|
|
// vias threaded in between are skipped: a via is a shape handle, not a
|
|
// destination, and a dashed spur hanging off one reads as a fault.
|
|
if (r.snapped) {
|
|
const daySnaps = (collectedSnaps[day.dayId] ??= {})
|
|
run.forEach((stop, i) => {
|
|
const s = r.snapped?.[stopAt[i]]
|
|
if (s) daySnaps[stopKey(stop)] = s
|
|
})
|
|
}
|
|
Object.assign(dayLegs, foldRouteRun(run, stopAt, r, mode))
|
|
return
|
|
} catch (err) {
|
|
// Almost always a rate limit on the shared routing host. Back off and try
|
|
// again; a run that still won't route (island hop, dead router) simply stays
|
|
// blank, and the totals say so by being partial rather than wrong.
|
|
const delay = RETRY_DELAYS_MS[attempt]
|
|
if (delay === undefined) return
|
|
// When the host says how long to wait, waiting less is just a second refusal.
|
|
const asked = err instanceof RoutingRefusedError && err.isRateLimit ? err.retryAfterMs : null
|
|
await sleep(Math.max(delay, asked ?? 0), controller.signal)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// One at a time, spaced out. The day sidebar can afford a small pool because it
|
|
// routes a single day; a road trip is every leg of every day at once, and the
|
|
// public routing hosts answer that with 429 after the first handful. Results are
|
|
// published as they land so the rail fills in instead of sitting empty.
|
|
void (async () => {
|
|
for (let i = 0; i < tasks.length; i++) {
|
|
if (controller.signal.aborted) return
|
|
const startedAt = performance.now()
|
|
await tasks[i]()
|
|
if (controller.signal.aborted) return
|
|
setLegsByDay({ ...collected })
|
|
setSnapByDay({ ...collectedSnaps })
|
|
setMissedByDay({ ...collectedMisses })
|
|
// Only pace what actually went out. RouteCalculator answers a repeat from its
|
|
// cache in well under a millisecond, and switching back into road trip mode is
|
|
// all repeats — waiting a second between those made a warm view feel broken.
|
|
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
|
|
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, controller.signal)
|
|
}
|
|
if (!controller.signal.aborted) setLoading(false)
|
|
})()
|
|
|
|
return () => controller.abort()
|
|
// planKey stands in for `plan`: same geometry, same legs. avoidKey, not `avoid`:
|
|
// a fresh array every render would re-route on every render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [planKey, routeProfile, distanceUnit, tripId, avoidKey])
|
|
|
|
/**
|
|
* Every leg known so far, by the two stops it connects.
|
|
*
|
|
* Filed under a key built from the stops' own coordinates, so which day it was fetched
|
|
* under stops mattering the moment a drive across midnight moves its stops to the next
|
|
* card — the road between two places is the road between them whichever date it is
|
|
* driven on.
|
|
*/
|
|
const allLegs = useMemo(() => {
|
|
const out: Record<string, RoutedLeg> = {}
|
|
for (const day of plan) Object.assign(out, legsByDay[day.dayId] ?? {})
|
|
return { ...out, ...seamLegs }
|
|
}, [plan, legsByDay, seamLegs])
|
|
|
|
// Which date each stop is actually reached on. Nothing is written to make it so; see
|
|
// `nightSpill.ts`.
|
|
const chains = useMemo(
|
|
() => spillChains(plan, quietDays, (a, b) => allLegs[legKey(a, b)]),
|
|
[plan, quietDays, allLegs],
|
|
)
|
|
|
|
/**
|
|
* Pairs a chain needs a road for and does not have.
|
|
*
|
|
* Only ever a seam between two stored days: everything inside one day was routed as a
|
|
* run. Left unrouted the rail shows a chain that stops halfway and the map draws the
|
|
* day in two pieces with a gap across the middle.
|
|
*/
|
|
const seams = useMemo(() => {
|
|
const out: { from: RoadtripStop; to: RoadtripStop; dayId: number }[] = []
|
|
const want = (from: RoadtripStop, to: RoadtripStop, dayId: number): void => {
|
|
// Routed as part of a day's own run — that request is rebuilt whenever its vias
|
|
// change, so there is nothing to catch up here.
|
|
if (legsByDay[from.ownerDayId]?.[legKey(from, to)]) return
|
|
// A seam already fetched is skipped only while it was fetched for the SHAPE it has
|
|
// now. Skipping it whenever any answer existed is what made a via on a seam do
|
|
// nothing at all: the first answer was cached under the pair, and dragging the
|
|
// point changed the request nobody was going to send again.
|
|
const have = seamLegs[legKey(from, to)]
|
|
if (have && have.shape === seamShape(from, viasByDay)) return
|
|
out.push({ from, to, dayId })
|
|
}
|
|
const routingChains = window
|
|
? [...plan, ...quietDays].sort((a, b) => a.dayNumber - b.dayNumber).filter(d => d.stops.length)
|
|
: chains
|
|
for (const chain of routingChains) {
|
|
for (let i = 0; i < chain.stops.length - 1; i++) {
|
|
const from = chain.stops[i]
|
|
const to = chain.stops[i + 1]
|
|
// Inside one stored day it was routed as part of that day's run.
|
|
if (from.ownerDayId === to.ownerDayId) continue
|
|
want(from, to, chain.dayId)
|
|
}
|
|
}
|
|
// And, when the traveller asks for one continuous drive, the road from each card's
|
|
// last stop to the next card's first — the one gap a day-at-a-time routing leaves.
|
|
if (connectDays) {
|
|
for (let d = 0; d < routingChains.length - 1; d++) {
|
|
const from = routingChains[d].stops[routingChains[d].stops.length - 1]
|
|
const to = routingChains[d + 1].stops[0]
|
|
if (from && to) want(from, to, routingChains[d + 1].dayId)
|
|
}
|
|
}
|
|
return out
|
|
}, [chains, plan, quietDays, window, legsByDay, seamLegs, viasByDay, connectDays])
|
|
const seamKey = seams.map(s => `${legKey(s.from, s.to)}#${seamShape(s.from, viasByDay)}`).join(';')
|
|
|
|
useEffect(() => {
|
|
if (!seams.length) return
|
|
const controller = new AbortController()
|
|
void (async () => {
|
|
for (const seam of seams) {
|
|
if (controller.signal.aborted) return
|
|
const mode = resolveLegMode(
|
|
{ isPlace: true, leg_transport_mode: seam.from.legMode },
|
|
{ isPlace: true, incoming_leg_transport_mode: seam.to.incomingLegMode },
|
|
days.find(d => d.id === seam.dayId)?.default_transport_mode || routeProfile,
|
|
)
|
|
try {
|
|
// The via points on this seam, threaded in the same way the day runs thread
|
|
// theirs. Without them a seam is the one stretch of the trip a via cannot
|
|
// shape: it is asked for on its own, so the points the traveller dropped on it
|
|
// never reached the router and dragging one did visibly nothing.
|
|
const shaping = (viasByDay[seam.from.ownerDayId] ?? [])
|
|
.filter(v => v.after_order_index === seam.from.ownerIndex)
|
|
.sort((a, b) => a.sequence - b.sequence)
|
|
const r = await calculateRouteWithLegs(
|
|
[
|
|
{ lat: seam.from.lat, lng: seam.from.lng },
|
|
...shaping.map(v => ({ lat: v.lat, lng: v.lng })),
|
|
{ lat: seam.to.lat, lng: seam.to.lng },
|
|
],
|
|
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid },
|
|
)
|
|
if (controller.signal.aborted) return
|
|
// One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail
|
|
// wants the whole drive: summed here, exactly as a day run folds its via legs
|
|
// back onto the stop pair they belong to.
|
|
if (!r.legs.length) continue
|
|
const merged = r.legs.length === 1 ? r.legs[0] : {
|
|
...r.legs[0],
|
|
distance: r.legs.reduce((sum, l) => sum + (l.distance ?? 0), 0),
|
|
duration: r.legs.reduce((sum, l) => sum + (l.duration ?? 0), 0),
|
|
}
|
|
setSeamLegs(prev => ({
|
|
...prev,
|
|
[legKey(seam.from, seam.to)]: {
|
|
seg: { ...merged, mode },
|
|
line: r.coordinates,
|
|
vias: [],
|
|
shape: seamShape(seam.from, viasByDay),
|
|
},
|
|
}))
|
|
} catch {
|
|
// A seam that will not route stays missing, exactly like any other leg that
|
|
// will not route: the chain shows a gap and the totals are partial rather than
|
|
// invented. Retried on the next run of this effect.
|
|
if (controller.signal.aborted) return
|
|
}
|
|
await sleep(REQUEST_SPACING_MS, controller.signal)
|
|
}
|
|
})()
|
|
return () => controller.abort()
|
|
// seamKey stands in for `seams`: same pairs, same requests. avoidKey, not `avoid`:
|
|
// a fresh array every render would re-ask for every seam on every render.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [seamKey, routeProfile, tripId, avoidKey])
|
|
|
|
return useMemo(() => assembleRoadtrip({ plan, quietDays, window, distanceUnit, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries,
|
|
labels: { start: t('roadtrip.window.resume'), end: t('roadtrip.window.stop') },
|
|
}), [plan, quietDays, window, distanceUnit, t, allLegs, snapByDay, missedByDay, loading, limits, vehicleKind, connectDays, boundaries])
|
|
}
|