Files
TREK/client/src/hooks/useRouteCalculation.ts
T
Maurice 9bc543a387 chore(tours): keep the client ratchets where they were
Snowshoe is no longer exported, useRouteCalculation takes the stage stops
type through dayRoutePlan, and tourPlannerFacts and useTourRoute hand the
saved duration, the ski check and the planned key to small helpers.
2026-10-20 19:07:13 +02:00

189 lines
11 KiB
TypeScript

import { useRoadtripSettings } from './useRoadtripSettings'
import { isServiceStopType } from '../components/Roadtrip/roadtripModel'
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { useAddonStore } from '../store/addonStore'
import { calculateRouteWithRetries, type RouteProfileKey } from '../components/Map/routeRetry'
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES, type DayRouteInputs } from '../components/Map/dayRoutePlan'
import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../utils/dayRouteVias'
import { resolveLegMode } from '../components/Planner/legMode'
import type { RoadtripVia } from '@trek/shared'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types'
const NO_ACCOMMODATIONS: Accommodation[] = []
const NO_VIAS: Record<number, RoadtripVia[]> = {}
/**
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
*
* `roadtripVias` are the road trip's vias by day: a road picked there is the road this
* day drives too (#2580). Absent, or empty while the road trip addon is off.
*/
export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments'> & { roadtripVias?: Record<number, RoadtripVia[]>; tourStageStops?: DayRouteInputs['tourStageStops'] }, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
const viasByDay = tripStore.roadtripVias ?? NO_VIAS
// MDT-02: a day holding one stage of a multi-day tour routes to that stage.
const tourStageStops = tripStore.tourStageStops
const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
// One flag per polyline of `route`: true where the stretch is walked, which the map
// draws dashed so it reads apart from the drive (#2532).
const [routeWalking, setRouteWalking] = useState<boolean[]>([])
// Charging stops / rest areas a plugin route places on the drawn line.
const [routeVias, setRouteVias] = useState<RouteVia[]>([])
const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false)
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
const places = useTripStore(s => s.places)
const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
// Recompute when the selected day's whole-day default mode changes (#1281) —
// including a remote collaborator's change, which arrives as day:updated and only
// touches state.days (otherwise not an effect dependency, so the map/mobile
// connectors would stay in the old mode while the sidebar already switched).
const selectedDayDefaultMode = useTripStore((s) => (selectedDayId ? s.days?.find(d => d.id === selectedDayId)?.default_transport_mode ?? null : null))
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
// hotel) unless the user turned the setting off — same gate as the sidebar.
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
// Recompute when the user flips km↔mi so leg distances (formatted at compute time)
// refresh instead of showing stale cached text (#1300).
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit)
const viaKey = useMemo(() => viaKeyOf(viasByDay), [viasByDay])
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
// Route is manual: only compute when explicitly enabled (the "show route" toggle).
if (!dayId || !enabled) { setRoute(null); setRouteWalking([]); setRouteSegments([]); setRouteVias([]); return }
// Read directly from store (not a render-phase ref) so callers after optimistic
// updates or non-optimistic deletes always see the latest assignments.
const state = useTripStore.getState()
const allDays = state.days || []
const runsWithHotel = buildDayRouteRuns(dayId, {
days: allDays,
assignments: Object.fromEntries(Object.entries(state.assignments || {}).map(([id, entries]) => [id, entries.filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type))])),
reservations: state.reservations || [],
accommodations,
optimizeFromAccommodation,
toursEnabled,
places,
tourStageStops,
})
const day = allDays.find(d => d.id === dayId)
// Anchors count the stored list, the hidden lodging and service rows included.
const viaLookup = dayViaLookup(state.assignments || {}, viasByDay)
const straightLines = (): [number, number][][] =>
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
if (runsWithHotel.length === 0) { setRoute(null); setRouteWalking([]); setRouteSegments([]); setRouteVias([]); return }
// Draw straight lines immediately for snappiness, then upgrade to the real
// OSRM (or plugin-provided) road geometry.
setRoute(straightLines())
setRouteWalking([])
const tripId = useTripStore.getState().trip?.id ?? null
const controller = new AbortController()
routeAbortRef.current = controller
// Per-leg routing (#1281): each leg uses its origin's saved mode, else the
// day's default, else the live picker profile. Legs are concatenated back into
// one polyline per run, and each segment is tagged with the mode it drew in so
// the connector shows the matching icon and duration.
const dayDefaultMode = day?.default_transport_mode || profile
try {
const polylines: [number, number][][] = []
const walking: boolean[] = []
const allLegs: RouteSegment[] = []
const allVias: RouteVia[] = []
for (const run of runsWithHotel) {
let polyline: [number, number][] = []
let polylineWalks = false
const finishPolyline = () => {
if (polyline.length >= 2) { polylines.push(polyline); walking.push(polylineWalks) }
}
// Append a leg's coordinates, dropping the point shared with the previous
// leg so concatenated legs don't leave a duplicate at each junction. A switch
// between walking and riding starts a new polyline at the shared point, so the
// walked part can be drawn in its own style without a gap (#2532).
const pushCoords = (coords: [number, number][], walks: boolean) => {
if (polyline.length > 0 && walks !== polylineWalks) {
const joint = polyline[polyline.length - 1]
finishPolyline()
polyline = [joint]
}
polylineWalks = walks
for (const c of coords) {
const last = polyline[polyline.length - 1]
if (last && last[0] === c[0] && last[1] === c[1]) continue
polyline.push(c)
}
}
// Neighbouring legs that resolve to the SAME mode travel as one
// multi-waypoint request — the router answers with one leg per pair, so
// the per-connector segments stay exactly as they were, and a day without
// per-leg overrides is back to a single call per run.
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 chunk = run.slice(i, end + 1)
const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number])
const { waypoints, stopAt } = threadVias(chunk, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to))
try {
// A busy router is waited out rather than left as straight lines until the next edit.
const r = await calculateRouteWithRetries(waypoints, { signal: controller.signal, profile: mode, tripId, dayId })
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight(), mode === 'walking')
// Leg k runs from chunk[k] to chunk[k + 1]; a hotel bookend says so on the
// segment, which is how the phone timeline finds it (#2501).
foldViaLegs(r.legs, stopAt, distanceUnit).forEach((leg, k) => {
const hotelBookend = chunk[k + 1] ? hotelBookendOf(chunk[k], chunk[k + 1]) : undefined
allLegs.push(hotelBookend ? { ...leg, mode, hotelBookend } : { ...leg, mode })
})
if (r.vias) allVias.push(...r.vias)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') throw err
// Routing failed for these legs — fall back to straight lines, no times.
pushCoords(straight(), mode === 'walking')
}
i = end
}
finishPolyline()
}
if (!controller.signal.aborted) { setRoute(polylines); setRouteWalking(walking); setRouteSegments(allLegs); setRouteVias(allVias) }
} catch (err: unknown) {
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) }
}
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places, viasByDay, tourStageStops])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
const transportSignature = useMemo(() => {
if (!selectedDayId) return ''
return reservationsForSignature
.filter(r => TRANSPORT_TYPES.includes(r.type))
.map(r => {
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
// Include endpoints so adding/moving a departure/arrival location re-routes.
const eps = (r.endpoints || []).map(e => `${e.role}@${e.lat ?? ''},${e.lng ?? ''}`).join(';')
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}:${eps}`
})
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
.join('|')
}, [reservationsForSignature, selectedDayId])
// Recalculate when assignments or transport positions for the SELECTED day change
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
useEffect(() => {
if (!selectedDayId) { setRoute(null); setRouteWalking([]); setRouteSegments([]); setRouteVias([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops, toursEnabled, places, viaKey, tourStageStops])
return { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}