mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
62f8b0deee
On a day that opens with a flight, where the traveller also put the hotel on the day as a stop of their own, the phone timeline started the day with a hotel row and the drive from that stop to the next place, above the flight, and then showed the same drive again in its own spot. hotelLegsForDay guessed the bookends from coordinates: the first segment leaving the hotel's spot was the morning leg. The route had correctly drawn no morning leg on that day, but the stop on the hotel's spot starts a leg there too, so that hop was taken for it. The route calculation now tags the two legs it draws from and to the accommodation, and the timeline reads that tag instead. The hotel end of a tagged leg still has to be the day's own stay, because the calculation keeps the previous day's legs until the new day has routed. The stop to stop connectors leave tagged legs alone, so one leg can no longer show up in both places.
152 lines
9.0 KiB
TypeScript
152 lines
9.0 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 { 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<RouteResult | null>(null)
|
|
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
|
// 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 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 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); 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,
|
|
})
|
|
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); setRouteSegments([]); setRouteVias([]); return }
|
|
|
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
|
// OSRM (or plugin-provided) road geometry.
|
|
setRoute(straightLines())
|
|
|
|
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 allLegs: RouteSegment[] = []
|
|
const allVias: RouteVia[] = []
|
|
for (const run of runsWithHotel) {
|
|
const polyline: [number, number][] = []
|
|
// Append a leg's coordinates, dropping the point shared with the previous
|
|
// leg so concatenated legs don't leave a duplicate at each junction.
|
|
const pushCoords = (coords: [number, number][]) => {
|
|
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())
|
|
// 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())
|
|
}
|
|
i = end
|
|
}
|
|
if (polyline.length >= 2) polylines.push(polyline)
|
|
}
|
|
if (!controller.signal.aborted) { setRoute(polylines); 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])
|
|
|
|
// 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); setRouteSegments([]); setRouteVias([]); return }
|
|
updateRouteForDay(selectedDayId)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops])
|
|
|
|
return { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
|
}
|