fix(maps): stop the day route drawing drives to airports you flew from

A flight leaving overnight contributes only its departure airport on the day
it leaves, and the evening hotel bookend was attaching to whatever point sat
at the day's edge. So the map drew a straight line from the airport you took
off from to the hotel you were checking into.

The same blind spot on the run builder produced the long rays: a day's order
is a bare wall clock, so an inbound long-haul whose departure clock reads late
sorts after the day's local stops, and its far departure airport got appended
to that run. OSRM answers such a pair with NoRoute and the fallback straightens
the whole chunk, taking the genuine local drive with it.

Both now ask whether the pair could be driven at all. A flight, train, ferry,
cruise or coach carries you out of the day's geography, so its departure point
has no drive back to tonight's hotel and its arrival point had no drive from
this morning's. A hire car is the opposite and stays out of that set: you keep
driving it, so a multi-day rental's pickup and drop-off points keep their hotel
legs. Alongside it, any leg touching a booking endpoint has to be within
MAX_DRIVE_KM, which catches the mis-sorted edges the role alone cannot see.

The sidebar gets both guards so its hotel connectors stay in step with the
drawn route; the mobile map and timeline read the same hook.
This commit is contained in:
Maurice
2026-08-29 11:57:05 +02:00
committed by Maurice
parent 3c865c9605
commit 9162f5d7ac
8 changed files with 328 additions and 38 deletions
+54 -17
View File
@@ -2,8 +2,9 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { calculateRouteWithLegs, withHotelBookends, type RouteProfileKey } from '../components/Map/RouteCalculator'
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems } from '../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../utils/dayOrder'
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport } from '../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../utils/dayOrder'
import { withinDriveRange } from '../utils/geo'
import { resolveLegMode } from '../components/Planner/legMode'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types'
@@ -76,7 +77,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Build a unified list of places + transports sorted by effective position.
type Entry =
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
const entries: Entry[] = merged.flatMap((item): Entry[] => {
if (item.type === 'place') {
const a = item.data
@@ -91,7 +92,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
}
if (item.type !== 'transport') return []
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
return [{ kind: 'transport', from, to, pos: item.sortKey }]
return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }]
})
// Group located places into driving runs.
@@ -104,23 +105,40 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// back-to-back transports (e.g. two flights on one day) would otherwise pair the
// first's arrival point with the second's departure point into a phantom
// [airport → airport] road route — that is the flight itself, not a drive (#1394).
//
// A booking endpoint also has to be REACHABLE from the stop before it. The day's
// order is a wall clock with no timezone behind it, so a long-haul arrival whose
// departure clock reads late sorts after the day's local stops — and its departure
// airport, an ocean away, then gets stapled to the last of them. The router answers
// that pair with NoRoute and the whole chunk falls back to a straight line, which is
// the long ray the map draws (#2133). Distance is only ever consulted for a leg
// touching a transport endpoint: two real places far apart are a drive someone
// planned, an airport that far from the stop before it never is.
type RunPoint = { lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null }
const runs: RunPoint[][] = []
let currentRun: RunPoint[] = []
let runHasPlace = false
const closeRun = () => {
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
currentRun = []
runHasPlace = false
}
for (const entry of entries) {
if (entry.kind === 'place') {
const prev = currentRun[currentRun.length - 1]
// The open run may be nothing but a far-away arrival endpoint — break rather
// than draw the ocean.
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
runHasPlace = true
} else if (entry.from || entry.to) {
if (entry.from) currentRun.push({ ...entry.from, isPlace: false })
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
currentRun = []
runHasPlace = false
const prev = currentRun[currentRun.length - 1]
if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false })
closeRun()
if (entry.to) currentRun.push({ ...entry.to, isPlace: false })
}
}
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
closeRun()
// Bookend the route with the day's accommodation: a hotel → first-stop run and
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
@@ -146,19 +164,38 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// (you dropped bags first, or swung back before checking out). A place before check-in (an
// airport you reach first, #1465), a later "home" stop on the checkout day (#1465), or a
// transport endpoint on an arrival/departure day (#1321, S7) all draw no bookend.
// A carrier endpoint at an edge additionally kills its own leg outright, however
// near the hotel it sits: you flew out of that airport, so nothing drove back from
// it tonight, and you flew into that one, so nothing drove to it this morning
// (#2133). Which of a transport's two points sits at the edge depends on its span —
// an overnight flight contributes only its departure on the day it leaves.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
const edgeInfo = (e?: Entry) =>
e ? { isPlace: e.kind === 'place', time: e.kind === 'place' ? e.time : null } : undefined
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop))
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
if (!e) return undefined
if (e.kind === 'place') return { isPlace: true, time: e.time }
const role: CarrierEdge = side === 'first'
? (e.from ? 'departure' : 'arrival')
: (e.to ? 'arrival' : 'departure')
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
}
const firstWay = flatPts[0]
const lastWay = flatPts[flatPts.length - 1]
const morningHotel = hotelPt(bookends?.morning)
const eveningHotel = hotelPt(bookends?.evening)
// Same reachability test as the run builder: a hotel is not joined to a point no
// one could have driven between.
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'))
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'))
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
const runsWithHotel: RunPoint[][] = withHotelBookends(
runs,
flatPts[0],
flatPts[flatPts.length - 1],
drawMorning ? hotelPt(bookends?.morning) : null,
drawEvening ? hotelPt(bookends?.evening) : null,
firstWay,
lastWay,
drawMorning ? morningHotel : null,
drawEvening ? eveningHotel : null,
)
// Transfer day with no activities: you check out of one accommodation and into