mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
8fd46c4c18
Small requests from the discussions, each built end to end: planner and map (cycling mode, date first headings, clear a day, stay times per day, off-route stops, map lock, compact unplanned markers, pending stays, cable car, transit quick picks), places (location filter, category from OSM, open in maps, geo: link, image from an attachment, enrichment on file import, places near a pin, place name language, Amap first in China), lists and costs (packed counter, inherited sharing, Markdown to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned places, travel only, without photos), journey (photo zoom, drafts, photo order, trip suggestion, day jump), atlas (region counter, region bucket list, places per country), vacay half company holidays, studio copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for a first start restore.
202 lines
11 KiB
TypeScript
202 lines
11 KiB
TypeScript
import { withHotelBookends } from './RouteCalculator'
|
|
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
|
|
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
|
import { withinDriveRange } from '@trek/shared/roadtrip'
|
|
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
|
|
|
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
|
|
|
|
/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */
|
|
export interface DayRoutePoint {
|
|
lat: number
|
|
lng: number
|
|
isPlace: boolean
|
|
leg_transport_mode?: string | null
|
|
incoming_leg_transport_mode?: string | null
|
|
/** Only on an accommodation bookend: the stay the day starts from or ends at. */
|
|
hotel?: 'morning' | 'evening'
|
|
}
|
|
|
|
/**
|
|
* Which accommodation bookend the leg between two neighbouring run points is, if any.
|
|
* A day that is nothing but the drive from one stay to the next counts as its morning
|
|
* leg, so it shows once at the top rather than at both ends (#2476).
|
|
*/
|
|
export function hotelBookendOf(from: DayRoutePoint, to: DayRoutePoint): 'morning' | 'evening' | undefined {
|
|
if (from.hotel === 'morning') return 'morning'
|
|
if (to.hotel === 'evening') return 'evening'
|
|
return undefined
|
|
}
|
|
|
|
/** Everything the plan depends on, passed in rather than read from the store, so the
|
|
* builder stays pure and one caller can run it over every day of a trip at once. */
|
|
export interface DayRouteInputs {
|
|
days: Day[]
|
|
assignments: AssignmentsMap
|
|
reservations: Reservation[]
|
|
accommodations: Accommodation[]
|
|
optimizeFromAccommodation: boolean | undefined
|
|
}
|
|
|
|
/**
|
|
* The runs of waypoints a day's route is drawn from, hotel bookends folded in.
|
|
*
|
|
* Lifted out of `useRouteCalculation` unchanged so the trip-wide overview draws the
|
|
* same line the day view does. Every rule below is load-bearing and issue-numbered —
|
|
* a second copy of it would drift the moment either surface got the next fix.
|
|
*/
|
|
export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
|
|
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input
|
|
// Without the stop a booked night wrote. The day plan hides that stop (the list
|
|
// and `useTripPlanner` both filter it) and draws the hotel as the day's bookends
|
|
// instead, so the road has to be built from the same stops the list shows. Left
|
|
// in, the stop sat first on a day that opened with a flight, since it carries no
|
|
// time of its own and a timed booking is seated behind the last timed stop: the
|
|
// map then drove from the hotel to the airport the traveller had not landed at
|
|
// yet, and back after the flight (#2430). Road trip mode keeps the stop; it plans
|
|
// its own drive and never comes through here.
|
|
const da = (assignments[String(dayId)] || [])
|
|
.filter(a => a.accommodation_id == null)
|
|
.slice()
|
|
.sort((a, b) => a.order_index - b.order_index)
|
|
const dayOrder = (id: number | null | undefined): number | null => {
|
|
if (id == null) return null
|
|
const d = allDays.find(x => x.id === id)
|
|
return d ? (d.day_number ?? allDays.indexOf(d)) : null
|
|
}
|
|
const thisOrder = dayOrder(dayId)
|
|
|
|
// The order the day plan shows is the order the map has to draw, so take it from
|
|
// the same place the plan does rather than rebuilding it here. getTransportForDay
|
|
// brings the span filter, the hotel/assignment exclusions and the leg expansion with
|
|
// it; getMergedItems places each leg by its own saved position, falling back to its
|
|
// time. Notes carry no coordinates, so none are passed.
|
|
const dayTransports = thisOrder == null ? [] : getTransportForDay({
|
|
reservations: allReservations.filter(r => TRANSPORT_TYPES.includes(r.type)),
|
|
dayId,
|
|
dayAssignmentIds: da.map(a => a.id),
|
|
days: allDays,
|
|
})
|
|
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
|
|
|
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; carrier: boolean }
|
|
const entries: Entry[] = merged.flatMap((item): Entry[] => {
|
|
if (item.type === 'place') {
|
|
const a = item.data
|
|
if (!a.place?.lat || !a.place?.lng) return []
|
|
// Kept on the day, shown on the map, but not driven to (#2532).
|
|
if ((a as { route_excluded?: boolean }).route_excluded) return []
|
|
return [{
|
|
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
|
|
// Per-segment travel mode (#1281): mode of the leg leaving this place.
|
|
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
|
|
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
|
|
incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null,
|
|
}]
|
|
}
|
|
if (item.type !== 'transport') return []
|
|
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
|
|
return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }]
|
|
})
|
|
|
|
// Group located places into driving runs. A transport WITH a location anchors the
|
|
// route to its departure point then breaks the run (you don't drive the flight); its
|
|
// arrival starts the next. A transport WITHOUT a location is ignored entirely. A run
|
|
// is only a real drive when it contains at least one actual place, or two back-to-back
|
|
// transports pair into a phantom [airport → airport] road route (#1394). A booking
|
|
// endpoint also has to be REACHABLE from the stop before it, or a long-haul arrival
|
|
// sorted in among the day's local stops gets stapled to one of them (#2133).
|
|
const runs: DayRoutePoint[][] = []
|
|
let currentRun: DayRoutePoint[] = []
|
|
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) {
|
|
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 })
|
|
}
|
|
}
|
|
closeRun()
|
|
|
|
// Bookend the route with the day's accommodation, so the drawn line matches the
|
|
// sidebar's hotel legs.
|
|
const day = allDays.find(d => d.id === dayId)
|
|
const bookends = day && optimizeFromAccommodation !== false
|
|
? getDayBookendHotels(day, allDays, accommodations)
|
|
: null
|
|
const flatPts: DayRoutePoint[] = []
|
|
for (const e of entries) {
|
|
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
|
|
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
|
|
}
|
|
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
|
|
// falls through to the day default for hotel-adjacent legs unless the place endpoint
|
|
// carries its own override. It says which end of the day it is, so the legs routed
|
|
// from it can be told apart from a stop planned on the hotel's own spot (#2501).
|
|
const hotelPt = (a: Accommodation | undefined, hotel: 'morning' | 'evening'): DayRoutePoint | null =>
|
|
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false, hotel } : null
|
|
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
|
|
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
|
|
// an arrival/departure day (#1321, #2133) all draw no bookend.
|
|
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 | undefined, side: 'first' | 'last') => {
|
|
if (!e) return undefined
|
|
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
|
|
const role: CarrierEdge = side === 'first'
|
|
? (e.from ? 'departure' : 'arrival')
|
|
: (e.to ? 'arrival' : 'departure')
|
|
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
|
|
}
|
|
// A carrier with a located endpoint today means the day changes geography by
|
|
// air/rail/sea — the check-in hotel is then the day's destination and the
|
|
// check-out hotel its origin, which the no-time bookend default respects (#2157).
|
|
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
|
|
const firstWay = flatPts[0]
|
|
const lastWay = flatPts[flatPts.length - 1]
|
|
const morningHotel = hotelPt(bookends?.morning, 'morning')
|
|
const eveningHotel = hotelPt(bookends?.evening, 'evening')
|
|
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
|
|
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
|
|
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
|
|
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
|
|
const runsWithHotel: DayRoutePoint[][] = withHotelBookends(
|
|
runs,
|
|
firstWay,
|
|
lastWay,
|
|
drawMorning ? morningHotel : null,
|
|
drawEvening ? eveningHotel : null,
|
|
)
|
|
|
|
// Transfer day with no activities: you check out of one accommodation and into
|
|
// another, so there are no waypoints for withHotelBookends to attach a leg to.
|
|
// Not when a flight, train, ferry or coach is booked on the day, located or not:
|
|
// that booking IS the move, and the road from one hotel to the other is exactly
|
|
// the stretch nobody drove (#2476). One saved without its stations leaves no
|
|
// waypoint behind, so the gates above never see it; no line beats a wrong one.
|
|
const dayHasCarrierBooking = dayTransports.some(r => isCarrierTransport(r))
|
|
if (runsWithHotel.length === 0 && drawMorning && drawEvening && !dayHasCarrierBooking) {
|
|
const m = hotelPt(bookends?.morning, 'morning')
|
|
const e = hotelPt(bookends?.evening, 'evening')
|
|
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
|
|
}
|
|
|
|
return runsWithHotel
|
|
}
|