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 { resolveLegMode } from '../components/Planner/legMode' import type { TripStoreState } from '../store/tripStore' import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types' const NO_ACCOMMODATIONS: Accommodation[] = [] /** * 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. */ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: RouteProfileKey = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) { const [route, setRoute] = useState<[number, number][][] | null>(null) const [routeInfo, setRouteInfo] = useState(null) const [routeSegments, setRouteSegments] = useState([]) // 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([]) // Charging stops / rest areas a plugin route places on the drawn line. const [routeVias, setRouteVias] = useState([]) 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(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 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) 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]) try { const r = await calculateRouteWithLegs(chunk.map(p => ({ lat: p.lat, lng: p.lng })), { 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). r.legs.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]) // 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]) return { route, routeWalking, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } }