Files
TREK/client/src/hooks/useRouteCalculation.ts
T
Maurice 3dbe76945d fix(roadtrip): the day view drives the road picked in the road trip
A chosen alternative or a hand-bent drive is stored as vias, and only the
road trip rail routed through them. The day route on the map, its
connectors in the day list and on the phone, and the overview of all days
asked the router for the bare stops, so they drew and measured the default
road again.

They now thread the same vias between two stops that follow each other in
the stored list, with the anchor counted the way the rail counts it, and
fold the answer back to one leg per stop pair. Only while the road trip
addon is on; avoid settings and the routing engine stay with the rail.

Refs #2580
2026-10-12 11:32:15 +02:00

185 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 { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../components/Map/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[]> }, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
const viasByDay = tripStore.roadtripVias ?? NO_VIAS
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,
})
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 {
const r = await calculateRouteWithLegs(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])
// 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])
return { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}