mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
e64ade010f
On a day a flight, train, ferry, cruise or bus leaves and lands, tonight's stay was seated by its check-in time. A hotel in Munich checked into from 15:00 stood ahead of a 15:15 flight out of Hamburg, so the drive went from the Munich hotel to Hamburg airport and reported the flight as nine hours late. A check-in is the earliest arrival, not an appointment: such a stay now waits behind the landing when it lies nearer the arrival than the departure, and its check-in only holds it when the ride lands before the room is ready. A time typed on the stop keeps its seat. With the stays at both ends of a day switched on: - a day with nothing but the next stay's check-in and a ride booked across it gets no drive from one hotel to the other, the same rule the day plan follows since #2476; - a stay is never joined by road to a terminal more than 2000 km away. The check moved to shared/roadtrip so Days and the road trip read one copy. A departure terminal's pin is dated: reaching it more than half a day late no longer moves the flight to the next day without a word, it is a late warning with the full minutes. A check-in evening also carries the stay's check-in time, as a label only. calculate_roadtrip runs the same code and its description says all of the above.
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
import type { RoadtripStop } from './planning-types';
|
|
|
|
export interface LatLng {
|
|
lat: number;
|
|
lng: number;
|
|
}
|
|
|
|
export interface Bbox {
|
|
south: number;
|
|
west: number;
|
|
north: number;
|
|
east: number;
|
|
}
|
|
|
|
const EARTH_RADIUS_KM = 6371;
|
|
const KM_PER_DEG_LAT = 111.32;
|
|
|
|
const toRad = (deg: number): number => (deg * Math.PI) / 180;
|
|
|
|
export function haversineKm(a: LatLng, b: LatLng): number {
|
|
const dLat = toRad(b.lat - a.lat);
|
|
const dLng = toRad(b.lng - a.lng);
|
|
const lat1 = toRad(a.lat);
|
|
const lat2 = toRad(b.lat);
|
|
const h = Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2);
|
|
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
}
|
|
|
|
/**
|
|
* Beyond this straight-line distance a leg is not a drive anyone makes between two stops:
|
|
* it is a booking's terminal that landed next to a local stop or a hotel, and the road
|
|
* router answers such a pair with NoRoute (#2133).
|
|
*
|
|
* Only ever applied to a leg that touches a booking's terminal. Two real places 2000 km
|
|
* apart are a long drive somebody planned; an airport 2000 km from the stop before it
|
|
* never is. The day plan and the road trip both read it from here.
|
|
*/
|
|
export const MAX_DRIVE_KM = 2000;
|
|
|
|
/** Whether two points are close enough to be joined by a road leg at all. */
|
|
export function withinDriveRange(a: LatLng, b: LatLng): boolean {
|
|
return haversineKm(a, b) <= MAX_DRIVE_KM;
|
|
}
|
|
|
|
export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number {
|
|
return projectOnSegment(p, a, b).distanceKm;
|
|
}
|
|
|
|
const DEGENERATE_SEGMENT_KM2 = 1e-12;
|
|
|
|
export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } {
|
|
const latScale = KM_PER_DEG_LAT;
|
|
const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2));
|
|
const ax = a.lng * lngScale;
|
|
const ay = a.lat * latScale;
|
|
const bx = b.lng * lngScale;
|
|
const by = b.lat * latScale;
|
|
const px = p.lng * lngScale;
|
|
const py = p.lat * latScale;
|
|
const dx = bx - ax;
|
|
const dy = by - ay;
|
|
const lenSq = dx * dx + dy * dy;
|
|
|
|
if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 };
|
|
|
|
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
|
|
const cx = ax + t * dx;
|
|
const cy = ay + t * dy;
|
|
return { distanceKm: Math.hypot(px - cx, py - cy), t };
|
|
}
|
|
|
|
export interface CorridorHit {
|
|
offRouteKm: number;
|
|
|
|
alongKm: number;
|
|
}
|
|
|
|
/**
|
|
* Where `p` meets the line: how far off it is and how far into the drive the closest
|
|
* point comes.
|
|
*
|
|
* `within` keeps the answer to one stretch of the drive, in km from its start. A road
|
|
* driven twice in a day, out of a hotel and back past it later, is the same line twice
|
|
* over, and the answer for the whole line is always the first pass; a caller that needs
|
|
* the second one asks for the stretch it lies in. No part of that stretch on the line
|
|
* means no answer.
|
|
*/
|
|
export function projectOntoRoute(
|
|
p: LatLng,
|
|
line: LatLng[],
|
|
within?: { fromKm: number; toKm: number },
|
|
): CorridorHit | null {
|
|
if (line.length < 2) return null;
|
|
let best = Number.POSITIVE_INFINITY;
|
|
let bestAlong = 0;
|
|
let travelled = 0;
|
|
let measured = false;
|
|
for (let i = 0; i < line.length - 1; i++) {
|
|
const a = line[i]!;
|
|
const b = line[i + 1]!;
|
|
const segment = haversineKm(a, b);
|
|
const start = travelled;
|
|
travelled += segment;
|
|
if (within && (travelled < within.fromKm || start > within.toKm)) continue;
|
|
measured = true;
|
|
let { distanceKm: d, t } = projectOnSegment(p, a, b);
|
|
if (within && segment > 0) {
|
|
// A segment the stretch starts or ends inside counts only up to that edge.
|
|
const lo = Math.max(0, (within.fromKm - start) / segment);
|
|
const hi = Math.min(1, (within.toKm - start) / segment);
|
|
if (t < lo || t > hi) {
|
|
t = t < lo ? lo : hi;
|
|
d = haversineKm(p, { lat: a.lat + t * (b.lat - a.lat), lng: a.lng + t * (b.lng - a.lng) });
|
|
}
|
|
}
|
|
if (d < best) {
|
|
best = d;
|
|
bestAlong = start + t * segment;
|
|
}
|
|
}
|
|
if (within && !measured) return null;
|
|
return { offRouteKm: best, alongKm: bestAlong };
|
|
}
|
|
|
|
function densify(line: LatLng[], maxStepDeg: number): LatLng[] {
|
|
if (line.length < 2) return line;
|
|
const out: LatLng[] = [line[0]!];
|
|
for (let i = 1; i < line.length; i++) {
|
|
const a = line[i - 1]!;
|
|
const b = line[i]!;
|
|
const span = Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lng - a.lng));
|
|
const steps = Math.max(1, Math.ceil(span / maxStepDeg));
|
|
for (let s = 1; s <= steps; s++) {
|
|
out.push({
|
|
lat: a.lat + ((b.lat - a.lat) * s) / steps,
|
|
lng: a.lng + ((b.lng - a.lng) * s) / steps,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] {
|
|
if (line.length === 0) return [];
|
|
const padLat = widthKm / KM_PER_DEG_LAT;
|
|
const tiles: Bbox[] = [];
|
|
let current: Bbox | null = null;
|
|
|
|
const padded = (b: Bbox): Bbox => {
|
|
const midLat = (b.north + b.south) / 2;
|
|
const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat)));
|
|
return {
|
|
south: b.south - padLat,
|
|
north: b.north + padLat,
|
|
west: b.west - padLng,
|
|
east: b.east + padLng,
|
|
};
|
|
};
|
|
|
|
let previous: LatLng | null = null;
|
|
|
|
for (const p of densify(line, maxSpanDeg / 2)) {
|
|
if (!current) {
|
|
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng };
|
|
previous = p;
|
|
continue;
|
|
}
|
|
const next: Bbox = {
|
|
south: Math.min(current.south, p.lat),
|
|
north: Math.max(current.north, p.lat),
|
|
west: Math.min(current.west, p.lng),
|
|
east: Math.max(current.east, p.lng),
|
|
};
|
|
const grown = padded(next);
|
|
if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) {
|
|
tiles.push(padded(current));
|
|
const from = previous ?? p;
|
|
current = {
|
|
south: Math.min(from.lat, p.lat),
|
|
north: Math.max(from.lat, p.lat),
|
|
west: Math.min(from.lng, p.lng),
|
|
east: Math.max(from.lng, p.lng),
|
|
};
|
|
} else {
|
|
current = next;
|
|
}
|
|
previous = p;
|
|
}
|
|
if (current) tiles.push(padded(current));
|
|
return tiles;
|
|
}
|
|
|
|
export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
|
|
if (line.length < 3) return line.slice();
|
|
let maxDist = 0;
|
|
let index = 0;
|
|
const first = line[0]!;
|
|
const last = line[line.length - 1]!;
|
|
for (let i = 1; i < line.length - 1; i++) {
|
|
const d = distanceToSegmentKm(line[i]!, first, last);
|
|
if (d > maxDist) {
|
|
maxDist = d;
|
|
index = i;
|
|
}
|
|
}
|
|
if (maxDist <= toleranceKm) return [first, last];
|
|
const left = simplifyLine(line.slice(0, index + 1), toleranceKm);
|
|
const right = simplifyLine(line.slice(index), toleranceKm);
|
|
return [...left.slice(0, -1), ...right];
|
|
}
|
|
|
|
export function pointAtMeters(line: LatLng[], metres: number): LatLng | null {
|
|
if (!line.length) return null;
|
|
if (line.length === 1 || metres <= 0) return line[0]!;
|
|
|
|
let covered = 0;
|
|
for (let i = 1; i < line.length; i++) {
|
|
const step = haversineKm(line[i - 1]!, line[i]!) * 1000;
|
|
if (covered + step >= metres) {
|
|
const t = step > 0 ? (metres - covered) / step : 0;
|
|
return {
|
|
lat: line[i - 1]!.lat + (line[i]!.lat - line[i - 1]!.lat) * t,
|
|
lng: line[i - 1]!.lng + (line[i]!.lng - line[i - 1]!.lng) * t,
|
|
};
|
|
}
|
|
covered += step;
|
|
}
|
|
return line[line.length - 1]!;
|
|
}
|
|
|
|
export function lineMetres(line: LatLng[]): number {
|
|
let total = 0;
|
|
for (let i = 1; i < line.length; i++) total += haversineKm(line[i - 1]!, line[i]!) * 1000;
|
|
return total;
|
|
}
|
|
|
|
export function sliceAtMeters(line: LatLng[], fromMetres: number, toMetres: number): LatLng[] {
|
|
if (line.length < 2 || toMetres <= fromMetres) return [];
|
|
const from = Math.max(0, fromMetres);
|
|
const out: LatLng[] = [];
|
|
let covered = 0;
|
|
for (let i = 1; i < line.length; i++) {
|
|
const step = haversineKm(line[i - 1]!, line[i]!) * 1000;
|
|
const segStart = covered;
|
|
const segEnd = covered + step;
|
|
covered = segEnd;
|
|
if (segEnd < from) continue;
|
|
if (segStart > toMetres) break;
|
|
|
|
const at = (m: number): LatLng => {
|
|
const t = step > 0 ? (m - segStart) / step : 0;
|
|
return {
|
|
lat: line[i - 1]!.lat + (line[i]!.lat - line[i - 1]!.lat) * t,
|
|
lng: line[i - 1]!.lng + (line[i]!.lng - line[i - 1]!.lng) * t,
|
|
};
|
|
};
|
|
if (!out.length) out.push(segStart >= from ? line[i - 1]! : at(from));
|
|
out.push(segEnd <= toMetres ? line[i]! : at(toMetres));
|
|
}
|
|
return out.length > 1 ? out : [];
|
|
}
|
|
|
|
export function boxAround(point: LatLng, radiusKm: number): Bbox {
|
|
const dLat = radiusKm / KM_PER_DEG_LAT;
|
|
const dLng = radiusKm / (KM_PER_DEG_LAT * Math.max(0.01, Math.cos(toRad(point.lat))));
|
|
return {
|
|
south: point.lat - dLat,
|
|
west: point.lng - dLng,
|
|
north: point.lat + dLat,
|
|
east: point.lng + dLng,
|
|
};
|
|
}
|
|
|
|
/** A stretch of the spine, in kilometres along it. */
|
|
export interface KmRange {
|
|
fromKm: number;
|
|
toKm: number;
|
|
}
|
|
|
|
/** A ride among a day's stops: the departure terminal, at `index`, and the arrival it jumps to. */
|
|
export interface RideGap {
|
|
index: number;
|
|
from: LatLng;
|
|
to: LatLng;
|
|
}
|
|
|
|
/**
|
|
* The rides on a day (#2428): each departure terminal followed by the arrival of the same
|
|
* booking. The line runs straight from the one to the other there, and the car is not on
|
|
* it, so a corridor search leaves the stretch out and drops what it finds under it.
|
|
*/
|
|
export function rideGaps(stops: readonly Pick<RoadtripStop, 'carrier' | 'lat' | 'lng'>[]): RideGap[] {
|
|
const out: RideGap[] = [];
|
|
stops.forEach((stop, i) => {
|
|
const next = stops[i + 1];
|
|
if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
|
|
out.push({ index: i, from: { lat: stop.lat, lng: stop.lng }, to: { lat: next.lat, lng: next.lng } });
|
|
}
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The ridden stretches as kilometres along the spine, in order. A ride whose two ends
|
|
* land on the same point of the spine, or that has no spine to be measured against, is
|
|
* no stretch. A terminal well off the line still projects onto its nearest point, so a
|
|
* ride is never dropped for standing away from the road.
|
|
*/
|
|
export function riddenRanges(spine: LatLng[], gaps: readonly { from: LatLng; to: LatLng }[]): KmRange[] {
|
|
const out: KmRange[] = [];
|
|
for (const gap of gaps) {
|
|
const a = projectOntoRoute(gap.from, spine)?.alongKm;
|
|
const b = projectOntoRoute(gap.to, spine)?.alongKm;
|
|
if (a === undefined || b === undefined) continue;
|
|
const range = { fromKm: Math.min(a, b), toKm: Math.max(a, b) };
|
|
if (range.toKm > range.fromKm) out.push(range);
|
|
}
|
|
return out.sort((a, b) => a.fromKm - b.fromKm);
|
|
}
|
|
|
|
// The terminals themselves stand at the ends of a ride and stay on the road.
|
|
const TERMINAL_SLACK_KM = 0.05;
|
|
|
|
export function inRiddenRange(ridden: readonly KmRange[], alongKm: number): boolean {
|
|
return ridden.some((r) => alongKm > r.fromKm + TERMINAL_SLACK_KM && alongKm < r.toKm - TERMINAL_SLACK_KM);
|
|
}
|
|
|
|
/**
|
|
* The spine cut into the pieces the car drives: the window, or the whole line, with every
|
|
* ridden stretch left out. Only the pieces to tile are cut, never the spine itself: every
|
|
* alongKm is a distance along the spine, and cutting that would renumber every hit and
|
|
* every stop the moment a window moved.
|
|
*/
|
|
export function drivenPieces(spine: LatLng[], ridden: readonly KmRange[], window?: KmRange | null): LatLng[][] {
|
|
const start = window?.fromKm ?? 0;
|
|
const end = window?.toKm ?? Number.POSITIVE_INFINITY;
|
|
const slice = (fromKm: number, toKm: number): LatLng[] =>
|
|
sliceAtMeters(spine, fromKm * 1000, Number.isFinite(toKm) ? toKm * 1000 : Number.MAX_SAFE_INTEGER);
|
|
const pieces: LatLng[][] = [];
|
|
if (!ridden.length) {
|
|
pieces.push(window ? slice(start, end) : spine);
|
|
} else {
|
|
let cursor = start;
|
|
for (const range of ridden) {
|
|
if (range.fromKm >= end) break;
|
|
if (range.fromKm > cursor) pieces.push(slice(cursor, range.fromKm));
|
|
cursor = Math.max(cursor, range.toKm);
|
|
}
|
|
if (cursor < end) pieces.push(slice(cursor, end));
|
|
}
|
|
return pieces.filter((piece) => piece.length > 1);
|
|
}
|