mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
154 lines
7.6 KiB
TypeScript
154 lines
7.6 KiB
TypeScript
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
|
import { useTripStore } from '../store/tripStore'
|
|
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
|
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
|
import type { TripStoreState } from '../store/tripStore'
|
|
import type { RouteSegment, RouteResult } from '../types'
|
|
|
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
|
|
|
/**
|
|
* 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: 'driving' | 'walking' | 'cycling' = 'driving') {
|
|
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
|
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
|
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
|
const routeAbortRef = useRef<AbortController | null>(null)
|
|
const reservationsForSignature = useTripStore((s) => s.reservations)
|
|
|
|
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([]); 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 currentAssignments = useTripStore.getState().assignments || {}
|
|
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
|
const allReservations = useTripStore.getState().reservations || []
|
|
const allDays = useTripStore.getState().days || []
|
|
const dayOrder = (id: number | null | undefined): number | null => {
|
|
if (id == null) return null
|
|
const d = allDays.find(x => x.id === id)
|
|
return d ? ((d as any).day_number ?? allDays.indexOf(d)) : null
|
|
}
|
|
const thisOrder = dayOrder(dayId)
|
|
|
|
// Transport reservations for this day with a known position — mirrors getTransportForDay semantics
|
|
const dayTransports = thisOrder == null ? [] : allReservations.filter(r => {
|
|
if (!TRANSPORT_TYPES.includes(r.type)) return false
|
|
const startId = r.day_id
|
|
if (startId == null) return false
|
|
const endId = r.end_day_id ?? startId
|
|
if (startId === endId) {
|
|
if (startId !== dayId) return false
|
|
} else {
|
|
const startOrder = dayOrder(startId)
|
|
const endOrder = dayOrder(endId)
|
|
if (startOrder == null || endOrder == null) return false
|
|
if (thisOrder < startOrder || thisOrder > endOrder) return false
|
|
}
|
|
const pos = r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position
|
|
return pos != null
|
|
})
|
|
|
|
// Build a unified list of places + transports sorted by effective position.
|
|
type Entry =
|
|
| { kind: 'place'; lat: number; lng: number; pos: number }
|
|
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
|
|
const entries: Entry[] = [
|
|
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
|
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
|
})),
|
|
...dayTransports.map(r => {
|
|
const { from, to } = getTransportRouteEndpoints(r, dayId)
|
|
return {
|
|
kind: 'transport' as const,
|
|
from,
|
|
to,
|
|
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
|
}
|
|
}),
|
|
].sort((a, b) => a.pos - b.pos)
|
|
|
|
// Group located places into driving runs.
|
|
// - A transport WITH a location anchors the route to its departure point (you
|
|
// travel there), then breaks the run (you don't drive the flight/train); its
|
|
// arrival point starts the next run.
|
|
// - A transport WITHOUT a location is ignored entirely — the places around it
|
|
// connect directly, as if the booking weren't there.
|
|
const runs: { lat: number; lng: number }[][] = []
|
|
let currentRun: { lat: number; lng: number }[] = []
|
|
for (const entry of entries) {
|
|
if (entry.kind === 'place') {
|
|
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
|
} else if (entry.from || entry.to) {
|
|
if (entry.from) currentRun.push(entry.from)
|
|
if (currentRun.length >= 2) runs.push(currentRun)
|
|
currentRun = []
|
|
if (entry.to) currentRun.push(entry.to)
|
|
}
|
|
}
|
|
if (currentRun.length >= 2) runs.push(currentRun)
|
|
|
|
const straightLines = (): [number, number][][] =>
|
|
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
|
|
|
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
|
|
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
|
// OSRM road geometry.
|
|
setRoute(straightLines())
|
|
|
|
const controller = new AbortController()
|
|
routeAbortRef.current = controller
|
|
try {
|
|
const polylines: [number, number][][] = []
|
|
const allLegs: RouteSegment[] = []
|
|
for (const run of runs) {
|
|
try {
|
|
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
|
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
|
allLegs.push(...r.legs)
|
|
} catch (err) {
|
|
if (err instanceof Error && err.name === 'AbortError') throw err
|
|
// OSRM failed for this run — fall back to a straight line, no times.
|
|
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
|
|
}
|
|
}
|
|
if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs) }
|
|
} catch (err: unknown) {
|
|
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
|
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
|
|
}
|
|
}, [enabled, profile])
|
|
|
|
// 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()
|
|
.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([]); return }
|
|
updateRouteForDay(selectedDayId)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
|
|
|
|
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
|
}
|