mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
514e2e62ee
The drive learns what a booked night at the edge of a day is, while nothing seats one yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one it sleeps in the way Days does, over real nights and in id order, and puts each hotel at the edge of the day as a stop of its own that belongs to no assignment. The schedule, the day window, the spill into the next morning, the sums and the connected days read a night spent at one hotel as a leg going nowhere, never cut or postpone the drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its stored index. That lookup now also puts a place added behind a terminal where it fell. The rail and the phone chain draw the hotel as a row of its own that opens the booking or the stay, offers no other ways and takes no via, in every language. Until a trip switches it on, every plan is the one stored.
269 lines
9.7 KiB
TypeScript
269 lines
9.7 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
|
|
import { lineLengthKm, parseTrack, planTrack, trackGapKm, type FollowTrackPlan } from './followTrack'
|
|
import type { LatLng } from './corridor'
|
|
import { isStoredStop } from '@trek/shared/roadtrip'
|
|
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
|
import type { RoadtripVias } from './useRoadtripVias'
|
|
|
|
/**
|
|
* The dialog state behind "this day follows that track".
|
|
*
|
|
* Kept out of the dialog because applying one is a long job — a round trip to the routing
|
|
* host per refinement — and a component that unmounts halfway would leave the day with
|
|
* half a chain of vias on it. Here it can be aborted, and here it can say how far it has
|
|
* got while it runs.
|
|
*/
|
|
|
|
/** A track of this trip, ready to be offered. */
|
|
export interface TrackChoice {
|
|
id: number
|
|
name: string
|
|
/** The colour the map already draws it in, so the list and the line agree. */
|
|
color: string | null
|
|
points: LatLng[]
|
|
lengthKm: number
|
|
/** How far the track runs from the open day at its closest — what orders the list. */
|
|
gapKm: number
|
|
}
|
|
|
|
/** What became of an applied track. */
|
|
export interface FollowTrackOutcome {
|
|
vias: number
|
|
strayKm: number
|
|
capped: boolean
|
|
}
|
|
|
|
export interface FollowTrack {
|
|
/** The day the dialog is open for, or null when it is closed. */
|
|
dayId: number | null
|
|
open: (dayId: number) => void
|
|
close: () => void
|
|
/** The trip's tracks, nearest to this day first. Empty until the dialog opens. */
|
|
tracks: TrackChoice[]
|
|
busy: boolean
|
|
/** Which refinement round is running, for a dialog that has to say something. */
|
|
round: number
|
|
error: 'route' | 'save' | null
|
|
outcome: FollowTrackOutcome | null
|
|
apply: (trackId: number) => Promise<void>
|
|
/** Drops every via of the open day, so it drives the way the router would. */
|
|
clear: () => Promise<void>
|
|
/** How many vias the open day carries — what makes clearing worth offering. */
|
|
viaCount: number
|
|
/**
|
|
* The track the open day follows, if it follows one.
|
|
*
|
|
* Read back from the day rather than remembered from the run that applied it: the point
|
|
* of storing it at all is that it survives a reload, and a field that only ever held
|
|
* what this session did would look right until somebody refreshed.
|
|
*/
|
|
current: { name: string; strayKm: number | null } | null
|
|
/** The name of the track each day follows, for the rail's badge. */
|
|
namesByDay: Record<number, string>
|
|
/**
|
|
* Whether the trip holds a track at all.
|
|
*
|
|
* Read off the column rather than off `tracks`, which is empty until the dialog opens
|
|
* and would therefore never let it be opened. It is what keeps the rail from offering
|
|
* a control whose only possible answer is "no tracks in this trip".
|
|
*/
|
|
available: boolean
|
|
}
|
|
|
|
/** A place is a track when it carries a line. */
|
|
export interface TrackPlace {
|
|
id: number
|
|
name?: string | null
|
|
route_geometry?: string | null
|
|
route_color?: string | null
|
|
}
|
|
|
|
/** Waiting a beat between rounds: the public routing hosts allow about one call a second. */
|
|
export const ROUND_PAUSE_MS = 400
|
|
|
|
/** How long to wait before the one retry a round gets. */
|
|
export const RETRY_PAUSE_MS = 1500
|
|
|
|
const sleep = (ms: number): Promise<void> => new Promise(resolve => { setTimeout(resolve, ms) })
|
|
|
|
export function useFollowTrack(
|
|
tripId: number | string | null,
|
|
places: TrackPlace[],
|
|
routes: RoadtripRoutes,
|
|
vias: RoadtripVias,
|
|
): FollowTrack {
|
|
const [dayId, setDayId] = useState<number | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
const [round, setRound] = useState(0)
|
|
const [error, setError] = useState<'route' | 'save' | null>(null)
|
|
const [outcome, setOutcome] = useState<FollowTrackOutcome | null>(null)
|
|
const abort = useRef<AbortController | null>(null)
|
|
|
|
/**
|
|
* The day's stored stops, in their stored order, wherever the cards draw them.
|
|
*
|
|
* A via is filed behind a stored stop, by its index (`legs` in the plan is that index),
|
|
* so the track is fitted between those and nothing else: not the markers a day window
|
|
* adds, not a terminal and not the hotel a day starts or ends at, each of which would
|
|
* shift every via behind it one leg on. Read across every card because a night drive
|
|
* draws some of them on the next one.
|
|
*/
|
|
const stops = useMemo<LatLng[]>(() => routes.days
|
|
.flatMap(d => d.stops)
|
|
.filter(s => isStoredStop(s) && s.ownerDayId === dayId)
|
|
.sort((a, b) => a.ownerIndex - b.ownerIndex)
|
|
.map(s => ({ lat: s.lat, lng: s.lng })), [routes.days, dayId])
|
|
|
|
/**
|
|
* Parsed only while the dialog is open.
|
|
*
|
|
* A track runs to tens of thousands of points and a trip can hold a dozen of them, so
|
|
* parsing them to render a rail nobody opened would cost more than the rail does. The
|
|
* memo hangs off `dayId` as well as `places` for exactly that reason.
|
|
*/
|
|
const tracks = useMemo<TrackChoice[]>(() => {
|
|
if (dayId === null) return []
|
|
const found: TrackChoice[] = []
|
|
for (const place of places) {
|
|
const points = parseTrack(place.route_geometry)
|
|
if (points.length < 2) continue
|
|
found.push({
|
|
id: place.id,
|
|
name: place.name || '',
|
|
color: place.route_color ?? null,
|
|
points,
|
|
lengthKm: lineLengthKm(points),
|
|
gapKm: trackGapKm(points, stops),
|
|
})
|
|
}
|
|
// Nearest first: a trip carrying every track of a three-week tour should still open
|
|
// on the one that runs along today's drive.
|
|
return found.sort((a, b) => a.gapKm - b.gapKm)
|
|
}, [dayId, places, stops])
|
|
|
|
const open = useCallback((next: number) => {
|
|
setDayId(next)
|
|
setOutcome(null)
|
|
setError(null)
|
|
setRound(0)
|
|
}, [])
|
|
|
|
const close = useCallback(() => {
|
|
abort.current?.abort()
|
|
abort.current = null
|
|
setDayId(null)
|
|
setBusy(false)
|
|
setRound(0)
|
|
}, [])
|
|
|
|
const apply = useCallback(async (trackId: number) => {
|
|
const track = tracks.find(t => t.id === trackId)
|
|
if (!track || dayId === null || stops.length < 2 || busy) return
|
|
|
|
const controller = new AbortController()
|
|
abort.current = controller
|
|
setBusy(true)
|
|
setError(null)
|
|
setOutcome(null)
|
|
setRound(0)
|
|
|
|
let plan: FollowTrackPlan
|
|
let spent = 0
|
|
try {
|
|
plan = await planTrack({
|
|
track: track.points,
|
|
stops,
|
|
signal: controller.signal,
|
|
onRound: n => { setRound(n) },
|
|
route: async waypoints => {
|
|
// Paced before the call rather than after it, so a plan that fits on its first
|
|
// round costs nothing in waiting — and given one second chance, because a
|
|
// refinement is several calls in a row against a host that allows about one a
|
|
// second, and losing the whole loop to a single 429 would restart it at nothing.
|
|
if (spent > 0) await sleep(ROUND_PAUSE_MS)
|
|
spent += 1
|
|
for (let attempt = 0; ; attempt++) {
|
|
try {
|
|
const r = await calculateRouteWithLegs(waypoints, {
|
|
signal: controller.signal,
|
|
profile: 'driving',
|
|
tripId,
|
|
dayId,
|
|
})
|
|
return r.coordinates.map(([lat, lng]) => ({ lat, lng }))
|
|
} catch (err) {
|
|
if (attempt >= 1 || controller.signal.aborted) throw err
|
|
await sleep(RETRY_PAUSE_MS)
|
|
}
|
|
}
|
|
},
|
|
})
|
|
} catch {
|
|
if (!controller.signal.aborted) setError('route')
|
|
setBusy(false)
|
|
return
|
|
}
|
|
|
|
if (controller.signal.aborted) { setBusy(false); return }
|
|
|
|
try {
|
|
// The chain and the name it came from, in one write: a day claiming to follow a
|
|
// road whose vias never landed would be worse than a day claiming nothing.
|
|
await vias.addMany(dayId, plan.vias, plan.legs, { place_id: trackId, stray_km: plan.strayKm })
|
|
setOutcome({ vias: plan.vias.length, strayKm: plan.strayKm, capped: plan.capped })
|
|
} catch {
|
|
setError('save')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}, [tracks, dayId, stops, busy, tripId, vias])
|
|
|
|
const viaCount = dayId === null ? 0 : (vias.byDay[dayId]?.length ?? 0)
|
|
|
|
/**
|
|
* The name behind each day's stored track id.
|
|
*
|
|
* A lookup over `places` rather than a parse: naming a line costs a row, and the rail
|
|
* asks for this on every render.
|
|
*/
|
|
const namesByDay = useMemo(() => {
|
|
const names: Record<number, string> = {}
|
|
const byId = new Map(places.map(p => [p.id, p]))
|
|
for (const [day, track] of Object.entries(vias.trackByDay)) {
|
|
const place = byId.get(track.place_id)
|
|
if (place?.name) names[Number(day)] = place.name
|
|
}
|
|
return names
|
|
}, [places, vias.trackByDay])
|
|
|
|
const current = useMemo(() => {
|
|
if (dayId === null) return null
|
|
const track = vias.trackByDay[dayId]
|
|
const name = namesByDay[dayId]
|
|
return track && name ? { name, strayKm: track.stray_km } : null
|
|
}, [dayId, vias.trackByDay, namesByDay])
|
|
// A cheap look at the column, not a parse: this runs on every planner render, and the
|
|
// question is only whether anything at all was ever imported.
|
|
const available = useMemo(() => places.some(p => (p.route_geometry?.length ?? 0) > 2), [places])
|
|
|
|
const clear = useCallback(async () => {
|
|
if (dayId === null || busy) return
|
|
setBusy(true)
|
|
setError(null)
|
|
try {
|
|
// Every leg of the day, so nothing is left behind on a leg the track never reached.
|
|
const legs = Array.from({ length: Math.max(0, stops.length - 1) }, (_, i) => i)
|
|
// null rather than nothing: dropping the points is also dropping the claim.
|
|
await vias.addMany(dayId, [], legs, null)
|
|
setOutcome({ vias: 0, strayKm: 0, capped: false })
|
|
} catch {
|
|
setError('save')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}, [dayId, busy, stops.length, vias])
|
|
|
|
return { dayId, open, close, tracks, busy, round, error, outcome, apply, clear, viaCount, available, current, namesByDay }
|
|
}
|