mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fa35966ab8
The road trip routed straight from one stop to the next, whatever stood between them: a flight booked for the afternoon became an eight-hour drive on the map, with the hotel reached by road from a town the traveller had flown out of (issue #2428). The day plan has treated such bookings as carriers for a while, and the drive now reads them the same way. A flight, train, ferry, cruise or bus with located terminals is a seam. Its two terminals are seated among the day's stops where the day plan lists the booking, as stops of their own: no number, no stay, no kind, not draggable, and an id below every assignment and every automatic night, because they stand for none. The road ends at the departure terminal and starts again at the arrival one; the leg between them is the ride, in the booking's own mode, with the timetable's minutes and no distance, and no router is ever asked for it. The departure terminal is pinned a check-in ahead of the timetable and left on the timetable's minute, so a drive that cannot make the airport in time is late the way a missed pin is. A ride that lands on a later day seats its arrival at the start of that day; with connected days the join between the two is the ride, and with daily travel times set the ride runs on the booking's clock through the night, with no day end placed on a flight. The map draws the ride as the booking's own arc, the one the day plan already draws, beside the roads. The day's distance and driving time count the roads only, the range budget spends nothing on a ride, and after one the car cannot take it starts afresh. The corridor search leaves the ride out and drops a hit under the flight path; a via cannot be filed against a terminal, and other ways of driving are offered for the road into a departure terminal only. The server plan reads the same three tables and seats the same stops, so calculate_roadtrip answers the same as the rail. Closes #2428
437 lines
17 KiB
TypeScript
437 lines
17 KiB
TypeScript
import { chronoOrder } from '../day/chrono-order';
|
|
import type { CarrierTerminal, RoadtripStop, RoutedLeg } from './planning-types';
|
|
import { formatClock, formatDurationShort, parseClock } from './roadtripModel';
|
|
|
|
/**
|
|
* Bookings the traveller rides and then leaves behind, the same set the day plan calls a
|
|
* carrier (`isCarrierTransport` in the client): the vehicle carries them out of the day's
|
|
* geography and keeps going without them. A hire car, a taxi or a transit hop is not one
|
|
* of these, because the traveller stays in the drive's geography the whole way.
|
|
*
|
|
* On the road trip such a booking is a seam: the road ends at the terminal it leaves
|
|
* from and starts again at the one it lands at. Routing straight across it drew a
|
|
* two-thousand-kilometre drive where a flight was booked (#2428).
|
|
*/
|
|
export const CARRIER_TYPES = ['flight', 'train', 'ferry', 'cruise', 'bus'] as const;
|
|
|
|
export function isCarrierType(type: string | null | undefined): boolean {
|
|
return !!type && (CARRIER_TYPES as readonly string[]).includes(type);
|
|
}
|
|
|
|
/** A leg mode that is a ride rather than a road: the booking's own type is the mode. */
|
|
export function isCarrierMode(mode: string | null | undefined): boolean {
|
|
return isCarrierType(mode);
|
|
}
|
|
|
|
/**
|
|
* How long before the timetable's departure the traveller has to be at the terminal.
|
|
*
|
|
* The pin on the departure terminal is this much before the departure, not the departure
|
|
* itself: a drive that reaches the airport as the doors close is late, however the model
|
|
* counts it. The ride then leaves at the timetable's minute.
|
|
*/
|
|
export const CHECK_IN_MINUTES: Record<string, number> = {
|
|
flight: 60,
|
|
cruise: 60,
|
|
ferry: 30,
|
|
train: 10,
|
|
bus: 10,
|
|
};
|
|
|
|
/**
|
|
* Whether the car goes along. A range budget carries across a ride the car takes too
|
|
* (a car ferry, a motorail) and starts afresh after one it cannot (nobody flies a car),
|
|
* because the tank the drive continues on is a different tank.
|
|
*/
|
|
export function carriesTheCar(type: string): boolean {
|
|
return type === 'ferry' || type === 'train';
|
|
}
|
|
|
|
/** A booking as either side stores it: the row with its endpoints and positions joined on. */
|
|
export interface CarrierBooking {
|
|
id: number;
|
|
type: string;
|
|
title: string;
|
|
day_id?: number | null;
|
|
end_day_id?: number | null;
|
|
/** 'HH:mm' or 'YYYY-MM-DDTHH:mm', as the reservation form writes it. */
|
|
reservation_time?: string | null;
|
|
reservation_end_time?: string | null;
|
|
/** JSON as stored, or the parsed object; only `legs` is read here. */
|
|
metadata?: string | Record<string, unknown> | null;
|
|
endpoints?: readonly {
|
|
role: string;
|
|
sequence?: number | null;
|
|
name: string;
|
|
code?: string | null;
|
|
lat: number | null;
|
|
lng: number | null;
|
|
}[];
|
|
day_positions?: Record<string, number> | null;
|
|
day_plan_position?: number | null;
|
|
}
|
|
|
|
/** One end of a seam: where, when and on which day the drive stops or resumes. */
|
|
export interface CarrierEnd {
|
|
dayId: number;
|
|
/** Where the terminal sits among the day's stops, when somebody placed the booking by hand. */
|
|
position: number | null;
|
|
/** The timetable's clock at this terminal, 'HH:mm', null when the booking names none. */
|
|
clock: string | null;
|
|
name: string;
|
|
code: string | null;
|
|
lat: number;
|
|
lng: number;
|
|
}
|
|
|
|
export interface CarrierSeam {
|
|
reservationId: number;
|
|
type: string;
|
|
title: string;
|
|
departure: CarrierEnd;
|
|
arrival: CarrierEnd;
|
|
}
|
|
|
|
interface LegRecord {
|
|
dep_day_id?: number | null;
|
|
dep_time?: string | null;
|
|
arr_day_id?: number | null;
|
|
arr_time?: string | null;
|
|
day_positions?: Record<string, number> | null;
|
|
}
|
|
|
|
function parseMetadata(raw: CarrierBooking['metadata']): Record<string, unknown> {
|
|
if (!raw) return {};
|
|
if (typeof raw !== 'string') return raw;
|
|
try {
|
|
let parsed: unknown = JSON.parse(raw);
|
|
// A booking saved by an earlier bug carries its JSON encoded twice; the day plan
|
|
// unwraps it once more on read and so does this.
|
|
if (typeof parsed === 'string') parsed = JSON.parse(parsed);
|
|
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** The legs of a booking with a stopover, in order; none for a direct one. */
|
|
function legsOf(booking: CarrierBooking): LegRecord[] {
|
|
const legs = parseMetadata(booking.metadata).legs;
|
|
return Array.isArray(legs) ? (legs as LegRecord[]) : [];
|
|
}
|
|
|
|
/** 'HH:mm' out of either form the reservation columns hold, null for anything else. */
|
|
export function carrierClock(value: string | null | undefined): string | null {
|
|
if (!value) return null;
|
|
const text = value.includes('T') ? value.slice(value.indexOf('T') + 1) : value;
|
|
const minutes = parseClock(text);
|
|
return minutes === null ? null : formatClock(minutes);
|
|
}
|
|
|
|
function positionOn(positions: Record<string, number> | null | undefined, dayId: number): number | null {
|
|
const value = positions?.[String(dayId)] ?? (positions as Record<number, number> | null | undefined)?.[dayId];
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
/**
|
|
* The seam a booking makes, or null when it makes none: a booking on no day is not on the
|
|
* plan, and one without a located terminal at BOTH ends cannot say where the drive stops
|
|
* or where it goes on, so it is left to the day plan.
|
|
*
|
|
* A booking with a stopover is one seam from its first departure to its last arrival. The
|
|
* change of planes happens inside the ride, and the drive has no say in it.
|
|
*/
|
|
export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
|
|
if (!isCarrierType(booking.type)) return null;
|
|
const located = (booking.endpoints ?? [])
|
|
.filter(
|
|
(e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng),
|
|
)
|
|
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
|
|
const from = located.find((e) => e.role === 'from') ?? located[0];
|
|
const to = [...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1];
|
|
if (!from || !to || from === to) return null;
|
|
|
|
const legs = legsOf(booking);
|
|
const first = legs[0];
|
|
const last = legs[legs.length - 1];
|
|
const depDayId = first?.dep_day_id ?? booking.day_id ?? null;
|
|
if (depDayId == null) return null;
|
|
const arrDayId = last?.arr_day_id ?? booking.end_day_id ?? depDayId;
|
|
const depClock = carrierClock(first?.dep_time ?? booking.reservation_time);
|
|
const arrClock = carrierClock(last?.arr_time ?? booking.reservation_end_time);
|
|
const bookingPosition = (dayId: number): number | null =>
|
|
positionOn(booking.day_positions, dayId) ??
|
|
(typeof booking.day_plan_position === 'number' ? booking.day_plan_position : null);
|
|
|
|
return {
|
|
reservationId: booking.id,
|
|
type: booking.type,
|
|
title: booking.title,
|
|
departure: {
|
|
dayId: depDayId,
|
|
position: positionOn(first?.day_positions, depDayId) ?? bookingPosition(depDayId),
|
|
clock: depClock,
|
|
name: from.name,
|
|
code: from.code ?? null,
|
|
lat: from.lat!,
|
|
lng: from.lng!,
|
|
},
|
|
arrival: {
|
|
dayId: arrDayId,
|
|
position: positionOn(last?.day_positions, arrDayId) ?? bookingPosition(arrDayId),
|
|
clock: arrClock,
|
|
name: to.name,
|
|
code: to.code ?? null,
|
|
lat: to.lat!,
|
|
lng: to.lng!,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Minutes of the ride as the clocks tell them, across the days the booking spans.
|
|
*
|
|
* Wall clocks on purpose, not elapsed time: the chain runs on the local clock, and after
|
|
* landing the day goes on in the destination's time. A ride that lands earlier on the
|
|
* clock than it left (westward across the date line) cannot be told from a ride of no
|
|
* length this way and counts as one. Null when either clock is missing.
|
|
*/
|
|
export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null {
|
|
const dep = parseClock(seam.departure.clock);
|
|
const arr = parseClock(seam.arrival.clock);
|
|
if (dep === null || arr === null) return null;
|
|
return Math.max(0, arr + Math.max(0, dayDelta) * 1440 - dep);
|
|
}
|
|
|
|
/** Below the automatic nights (-2e9 and down): a terminal is no assignment and no night. */
|
|
const TERMINAL_ID_BASE = -3_000_000_000;
|
|
|
|
export function terminalAssignmentId(reservationId: number, role: CarrierTerminal['role']): number {
|
|
return TERMINAL_ID_BASE - reservationId * 2 - (role === 'arrival' ? 1 : 0);
|
|
}
|
|
|
|
/** Whether a stop is a carrier terminal, which is what every writer has to refuse. */
|
|
export function isCarrierStop(stop: Pick<RoadtripStop, 'carrier'> | null | undefined): boolean {
|
|
return !!stop?.carrier;
|
|
}
|
|
|
|
function terminalStop(
|
|
seam: CarrierSeam,
|
|
role: CarrierTerminal['role'],
|
|
dayId: number,
|
|
ownerIndex: number,
|
|
): RoadtripStop {
|
|
const end = role === 'departure' ? seam.departure : seam.arrival;
|
|
const checkIn = CHECK_IN_MINUTES[seam.type] ?? 0;
|
|
const depart = parseClock(end.clock);
|
|
// The departure terminal is pinned a check-in ahead of the timetable and left at the
|
|
// timetable's minute; the arrival one is pinned at the timetable's minute and left at
|
|
// once. Neither has a stay of its own.
|
|
const time = role === 'departure' && depart !== null ? formatClock(depart - checkIn) : end.clock;
|
|
return {
|
|
carrier: {
|
|
reservationId: seam.reservationId,
|
|
type: seam.type,
|
|
role,
|
|
title: seam.title,
|
|
code: end.code,
|
|
at: end.clock,
|
|
},
|
|
assignmentId: terminalAssignmentId(seam.reservationId, role),
|
|
ownerDayId: dayId,
|
|
ownerIndex,
|
|
placeId: -seam.reservationId,
|
|
name: end.name,
|
|
lat: end.lat,
|
|
lng: end.lng,
|
|
time,
|
|
leaveAt: role === 'departure' ? end.clock : null,
|
|
dwellMinutes: role === 'departure' && depart !== null ? checkIn : 0,
|
|
checkInTime: null,
|
|
night: false,
|
|
endDay: false,
|
|
legMode: role === 'departure' ? seam.type : null,
|
|
incomingLegMode: role === 'arrival' ? seam.type : null,
|
|
stopType: null,
|
|
fillPercent: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The day's stops with the terminals of its rides seated among them.
|
|
*
|
|
* Each terminal takes the slot the day plan shows the booking in, worked out by the same
|
|
* rules (`getMergedItems` in the client): a position somebody dragged it to wins, otherwise
|
|
* it goes behind the last stop whose time is at or before its own, otherwise at the end,
|
|
* and the whole list is then read in clock order. Only the terminal moves, though. The
|
|
* stops keep the order they are stored in, which is the order the road trip has always
|
|
* driven them in, and the terminal is slotted in behind the stop that precedes it in the
|
|
* day plan's reading.
|
|
*
|
|
* A ride that leaves and lands on the same day seats its arrival right behind its
|
|
* departure: nothing is visited in between. One that lands on a later day seats the
|
|
* arrival on that day, where it opens the drive.
|
|
*
|
|
* `orderIndex` is each stop's stored `order_index`, which is what a dragged position is
|
|
* measured against; `clockOf` its time in minutes, or null.
|
|
*/
|
|
export function seatCarrierStops(
|
|
dayId: number,
|
|
stops: RoadtripStop[],
|
|
orderIndex: number[],
|
|
seams: CarrierSeam[],
|
|
): RoadtripStop[] {
|
|
const departing = seams.filter((s) => s.departure.dayId === dayId);
|
|
const arriving = seams.filter((s) => s.arrival.dayId === dayId && s.departure.dayId !== dayId);
|
|
if (!departing.length && !arriving.length) return stops;
|
|
|
|
type Item =
|
|
| { kind: 'stop'; index: number; key: number; minutes: number | null }
|
|
| { kind: 'end'; seam: CarrierSeam; role: CarrierTerminal['role']; key: number; minutes: number | null };
|
|
const base: Item[] = stops.map((stop, index) => ({
|
|
kind: 'stop',
|
|
index,
|
|
key: orderIndex[index] ?? index,
|
|
minutes: parseClock(stop.time ?? stop.checkInTime ?? null),
|
|
}));
|
|
const ends: { seam: CarrierSeam; role: CarrierTerminal['role']; end: CarrierEnd }[] = [
|
|
...departing.map((seam) => ({ seam, role: 'departure' as const, end: seam.departure })),
|
|
...arriving.map((seam) => ({ seam, role: 'arrival' as const, end: seam.arrival })),
|
|
];
|
|
const items: Item[] = [...base];
|
|
const lastKey = base.length ? Math.max(...base.map((b) => b.key)) : 0;
|
|
const firstKey = base.length ? Math.min(...base.map((b) => b.key)) : 0;
|
|
ends
|
|
.map((e) => ({ ...e, minutes: parseClock(e.end.clock) }))
|
|
.sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0))
|
|
.forEach((e, ti) => {
|
|
let key = e.end.position;
|
|
if (key === null) {
|
|
const minutes = e.minutes ?? 0;
|
|
let after = -Infinity;
|
|
for (const item of items)
|
|
if (item.minutes !== null && item.minutes <= minutes) after = Math.max(after, item.key);
|
|
// With no timed stop ahead of it, a departure closes the day, the way the day plan
|
|
// lists it. An arrival that landed overnight OPENS the day it lands on: the day
|
|
// plan happens to file it last there too, but read as a drive that puts the
|
|
// morning's stops on the road before the traveller has landed.
|
|
const opens = e.role === 'arrival';
|
|
key =
|
|
after === -Infinity
|
|
? opens
|
|
? firstKey - 0.5 - ti * 0.01
|
|
: lastKey + 0.5 + ti * 0.01
|
|
: after + 0.01 + ti * 0.001;
|
|
}
|
|
items.push({ kind: 'end', seam: e.seam, role: e.role, key, minutes: e.minutes });
|
|
});
|
|
items.sort((a, b) => a.key - b.key || (a.kind === 'stop' ? -1 : 1));
|
|
// Untimed stops keep their place behind the timed item before them, as in the day plan.
|
|
// An arrival opening the day is timed and first, so the untimed stops behind it follow
|
|
// it rather than sorting ahead of it on a clock they never had.
|
|
const read = chronoOrder(items, (item) => item.minutes);
|
|
|
|
// Where each terminal lands: behind the stop before it in the day plan's reading, in
|
|
// the stored order. Two terminals behind the same stop keep their reading order.
|
|
const slots: { seam: CarrierSeam; role: CarrierTerminal['role']; after: number }[] = [];
|
|
let lastStop = -1;
|
|
for (const item of read) {
|
|
if (item.kind === 'stop') lastStop = item.index;
|
|
else slots.push({ seam: item.seam, role: item.role, after: lastStop });
|
|
}
|
|
const out: RoadtripStop[] = [];
|
|
const emit = (slot: (typeof slots)[number]): void => {
|
|
const insertAt = out.filter((s) => !s.carrier).length;
|
|
out.push(terminalStop(slot.seam, slot.role, dayId, insertAt));
|
|
if (slot.role === 'departure' && slot.seam.arrival.dayId === dayId) {
|
|
out.push(terminalStop(slot.seam, 'arrival', dayId, insertAt));
|
|
}
|
|
};
|
|
for (const slot of slots.filter((s) => s.after < 0)) emit(slot);
|
|
stops.forEach((stop, index) => {
|
|
out.push(stop);
|
|
for (const slot of slots.filter((s) => s.after === index)) emit(slot);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The ride between a departure terminal and its arrival, in the shape of a routed leg.
|
|
*
|
|
* Its mode is the booking's type, so every reader that tells driving from walking tells
|
|
* a ride apart the same way. No line and no distance: the map draws the booking's own
|
|
* arc for it, and the line here is what the corridor and the via points measure along,
|
|
* which a flight is not. The minutes are what the chain needs.
|
|
*/
|
|
export function carrierLeg(from: RoadtripStop, to: RoadtripStop, minutes: number | null): RoutedLeg {
|
|
const seconds = (minutes ?? 0) * 60;
|
|
const a: [number, number] = [from.lat, from.lng];
|
|
const b: [number, number] = [to.lat, to.lng];
|
|
const durationText = minutes === null ? '' : formatDurationShort(seconds);
|
|
return {
|
|
seg: {
|
|
from: a,
|
|
to: b,
|
|
mid: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
|
|
distance: 0,
|
|
duration: seconds,
|
|
mode: from.carrier?.type ?? to.carrier?.type ?? 'flight',
|
|
distanceText: '',
|
|
durationText,
|
|
drivingText: durationText,
|
|
walkingText: durationText,
|
|
},
|
|
line: [],
|
|
vias: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The seams a run of stops asks for, when the run is a ride and not a road: one leg per
|
|
* consecutive pair, each the ride between them. Null for a run that is a road.
|
|
*/
|
|
export function carrierLegsFor(
|
|
run: RoadtripStop[],
|
|
mode: string,
|
|
dayDelta: (from: RoadtripStop, to: RoadtripStop) => number,
|
|
key: (from: RoadtripStop, to: RoadtripStop) => string,
|
|
): Record<string, RoutedLeg> | null {
|
|
if (!isCarrierMode(mode)) return null;
|
|
const legs: Record<string, RoutedLeg> = {};
|
|
for (let i = 0; i < run.length - 1; i++) {
|
|
const from = run[i]!;
|
|
const to = run[i + 1]!;
|
|
const dep = parseClock(from.carrier?.at);
|
|
const arr = parseClock(to.carrier?.at);
|
|
const minutes =
|
|
from.carrier && to.carrier && dep !== null && arr !== null
|
|
? Math.max(0, arr + Math.max(0, dayDelta(from, to)) * 1440 - dep)
|
|
: null;
|
|
legs[key(from, to)] = carrierLeg(from, to, minutes);
|
|
}
|
|
return legs;
|
|
}
|
|
|
|
/**
|
|
* The via points shaping the drive that leaves a stop. None leave a terminal: a via is
|
|
* filed by the position of a stored stop, and a terminal stands in for none, so the
|
|
* points filed at its index belong to the stop that really has that index.
|
|
*/
|
|
export function viasLeaving<V extends { day_id: number; after_order_index: number; sequence: number }>(
|
|
stop: Pick<RoadtripStop, 'carrier' | 'ownerDayId' | 'ownerIndex'>,
|
|
vias: readonly V[],
|
|
): V[] {
|
|
if (stop.carrier) return [];
|
|
return vias
|
|
.filter((v) => v.day_id === stop.ownerDayId && v.after_order_index === stop.ownerIndex)
|
|
.sort((a, b) => a.sequence - b.sequence);
|
|
}
|
|
|
|
/** The bookings whose ride the days draw, for the map to show their arcs beside the roads. */
|
|
export function carrierReservationIds(days: readonly { stops: readonly Pick<RoadtripStop, 'carrier'>[] }[]): number[] {
|
|
const ids = new Set<number>();
|
|
for (const day of days) for (const stop of day.stops) if (stop.carrier) ids.add(stop.carrier.reservationId);
|
|
return [...ids];
|
|
}
|