mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
154c88b48a
A visit's End could be edited and was shown in road trip mode, but the schedule never read it: the drive always left a stop after its stay, so a traveller who knew when they had to leave but not how long they would stand there had no way to say so. The End is now the time the drive leaves. RoadtripStop.leaveAt is filled from the visit's End (or its place's) on the client by useRoadtripRoutes and on the server by roadtrip-plan.service, so calculate_roadtrip and get_roadtrip_context agree with the planner, and computeSchedule takes it as the departure it already knew how to hold. With Start and End both set the stay is the time between them. With daily travel times on, the stay is the time until then, and an End before the window start starts the day early the way a Start does. Before the first fixed time the chain works backwards through an End as it does through a pinned Start. An End is read against the drive: the last passing of that clock time counts as missed when the drive went past it on the road or earlier the same day, anything else is the next one, so a drive across midnight neither waits a whole day nor pretends to be on time. A drive that gets there after the End leaves on arrival and files a missedLeave finding, shown beside the late badge in the rail and on the phone. The rail's STAY badge, the inspector's Stay tile, the phone stop sheet and the phone chain show the stay the End makes, with an "until 14:00" hint. It is read off the schedule and never written to duration_minutes: the stay belongs to the place, the End to one visit, and a stored copy would go stale as soon as the arrival moved. For such a stop the stay dialog, desktop and phone, shows that stay read only and offers to remove the End, through the visit's time route with an offline path (assignmentRepo.setTimes, setAssignmentTimes). While the road trip is on, the End field in both place forms says the drive leaves at that time. The dead checkoutAt in the plan service is gone. New strings in all 23 locales.
583 lines
29 KiB
TypeScript
583 lines
29 KiB
TypeScript
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
|
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, Accommodation, 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 EMPTY_ACCOMMODATIONS: Accommodation[] = []
|
|
|
|
const asStop = (a: Assignment, ownerDayId: number, ownerIndex: number, accommodations: Accommodation[]): RoadtripStop | null => {
|
|
const p = a.place
|
|
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number') return null
|
|
// Check-in only. A check-out is the LATEST the room has to be handed back, not the
|
|
// earliest anybody may leave, so it says nothing about when the drive sets off and
|
|
// has no business in the chain. It stays a booking detail, shown under Days.
|
|
const stay = accommodations.find(stay => stay.place_id === a.place_id && stay.start_day_id === ownerDayId)
|
|
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,
|
|
// The visit's End is when the drive leaves it. Unlike a check-out it is the
|
|
// traveller's own statement about this stop, and the stay then runs until it.
|
|
leaveAt: a.assignment_end_time ?? p.end_time ?? null,
|
|
checkInTime: stay?.check_in ?? 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[] = [],
|
|
accommodations: Accommodation[] = EMPTY_ACCOMMODATIONS,
|
|
): 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 = useRoadtripSettings(s => s.roadtrip_leg_minutes, tripId)
|
|
const dayMinutes = useRoadtripSettings(s => s.roadtrip_day_minutes, tripId)
|
|
const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent, tripId)
|
|
/**
|
|
* 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 = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
|
|
/**
|
|
* 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 = useRoadtripSettings(s => !!s.roadtrip_connect_days, tripId)
|
|
const startTime = useRoadtripSettings(s => s.roadtrip_day_start, tripId)
|
|
const endTime = useRoadtripSettings(s => s.roadtrip_day_end, tripId)
|
|
const endMode = useRoadtripSettings(s => s.roadtrip_day_end_mode, tripId)
|
|
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(tripId)
|
|
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, accommodations))
|
|
.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, accommodations])
|
|
|
|
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, accommodations))
|
|
.filter((s): s is RoadtripStop => s !== null)
|
|
.map((s, i) => ({ ...s, ownerIndex: i })),
|
|
}))
|
|
.filter(d => d.stops.length < 2)
|
|
}, [days, assignments, accommodations])
|
|
|
|
// 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>)[] = []
|
|
|
|
/**
|
|
* Ask for one run, and when the router refuses the whole of it, ask for its legs
|
|
* one at a time instead.
|
|
*
|
|
* A run travels as a single request because the answer carries a leg per waypoint
|
|
* pair, which is exactly what the rail wants, and one request is one slot of a
|
|
* host that allows about one a second. What that thrift costs is that a refusal is
|
|
* never local: OSRM answers `400 NoRoute` for the entire chain when one stop in the
|
|
* MIDDLE of it sits where it may not turn around, so a day lost every leg it had
|
|
* over a single cave car park while each of its pairs routed perfectly alone.
|
|
* `uTurnParam` in RouteCalculator removes the usual cause; this removes the SHAPE of
|
|
* the failure, so a stop that really cannot be reached costs its own two legs and
|
|
* not the day around them.
|
|
*
|
|
* The split is enqueued, not awaited, so the pairs queue up behind everything else
|
|
* and keep the same spacing as any other request: a day falling back must not turn
|
|
* into a burst at the one host that refused it.
|
|
*/
|
|
const enqueueRun = (day: PlanDay, dayLegs: Record<string, RoutedLeg>, run: RoadtripStop[], mode: string): void => {
|
|
/** The same stops, as one request per pair. A pair has nothing left to split. */
|
|
const splitIntoPairs = (): void => {
|
|
if (run.length <= 2) return
|
|
for (let i = 0; i < run.length - 1; i++) enqueueRun(day, dayLegs, [run[i], run[i + 1]], mode)
|
|
}
|
|
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
|
|
})
|
|
}
|
|
// What was asked for against what the road turned out to be. Only
|
|
// the second engine reports it, and only when it answered: an OSRM
|
|
// fallback leaves `avoidance` absent, which is the honest reading
|
|
// of "the weighting never happened". Collected here because the
|
|
// rail plans in the browser and never sees the server's own copy
|
|
// of this field — without it the "not honoured" badge could not
|
|
// appear at all, and a motorway-free drive that is not one read as
|
|
// if the setting had held.
|
|
if (r.avoidance) {
|
|
const missed = r.avoidance.asked.filter(cls => !r.avoidance!.achieved.includes(cls))
|
|
if (missed.length) {
|
|
collectedMisses[day.dayId] = [...new Set([...(collectedMisses[day.dayId] ?? []), ...missed])]
|
|
}
|
|
}
|
|
Object.assign(dayLegs, foldRouteRun(run, stopAt, r, mode))
|
|
return
|
|
} catch (err) {
|
|
if (controller.signal.aborted) return
|
|
const refusal = err instanceof RoutingRefusedError ? err : null
|
|
// Almost always a rate limit on the shared routing host, or a connection
|
|
// that dropped: back off and try again. A refusal the host MEANT is the
|
|
// one thing not worth repeating: it objects to these coordinates, and
|
|
// asking twice more only spends five seconds earning the same 400.
|
|
const delay = refusal && !refusal.isRateLimit ? undefined : RETRY_DELAYS_MS[attempt]
|
|
if (delay !== undefined) {
|
|
// When the host says how long to wait, waiting less is just a second refusal.
|
|
await sleep(Math.max(delay, (refusal?.isRateLimit ? refusal.retryAfterMs : null) ?? 0), controller.signal)
|
|
continue
|
|
}
|
|
// Nothing left to try. A run of three or more stops asks again one pair at
|
|
// a time, so a stop the router will not route THROUGH costs its own two
|
|
// legs instead of every leg of the day around it. Never after a rate limit:
|
|
// the answer to a host asking for less traffic is not four more requests.
|
|
if (!refusal?.isRateLimit) splitIntoPairs()
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
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) enqueueRun(day, dayLegs, run, mode)
|
|
}
|
|
|
|
// 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] ?? {})
|
|
// Seams first, this day's own runs over the top. `legsByDay` is replaced whole on
|
|
// every routing round while `seamLegs` only ever grows, so a pair that used to sit on
|
|
// a day boundary and now sits inside one day keeps an answer nobody re-asks for, and
|
|
// that older answer was shaped by the vias of a stop that has since moved. Where both
|
|
// exist the day run is the newer of the two, so it is the one to believe.
|
|
return { ...seamLegs, ...out }
|
|
}, [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(';')
|
|
/** When the last seam request went out, across every run of the effect below. */
|
|
const lastSeamRequestAt = useRef(0)
|
|
|
|
useEffect(() => {
|
|
if (!seams.length) return
|
|
const controller = new AbortController()
|
|
void (async () => {
|
|
for (const seam of seams) {
|
|
if (controller.signal.aborted) return
|
|
// Paced before the request, against a clock that outlives this effect.
|
|
//
|
|
// Waiting AFTER one instead spaced nothing: storing a seam changes
|
|
// seamLegs, which is a dependency of the seams memo, which shortens
|
|
// seamKey, which is this effect's first dependency — so React tore the
|
|
// effect down mid-wait, the cleanup aborted the sleep, and the next run
|
|
// fired straight away. Thirteen seams went out at round-trip speed, the
|
|
// shared routing hosts answered the tail with 429, and a refused seam
|
|
// writes no state, so nothing changed to make the effect try again: the
|
|
// map drew the trip in pieces and the totals came back short.
|
|
const since = performance.now() - lastSeamRequestAt.current
|
|
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, controller.signal)
|
|
if (controller.signal.aborted) return
|
|
lastSeamRequestAt.current = performance.now()
|
|
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
|
|
}
|
|
}
|
|
})()
|
|
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])
|
|
}
|