refactor(map): share the transport hop building between the Leaflet and GL maps

ReservationOverlay and reservationsMapbox each carried the booking type
list, the icon and arc table, the time zone aware duration and the loop
that turns a booking into waypoints, arcs and labels. All of that now
lives in transportHops.ts; each renderer keeps only its drawing.

The one difference stays a parameter: Leaflet asks for arcs that cross
the antimeridian to be copied 360 degrees over, the GL map does not
because it repeats features across world copies itself. Leaflet still
adds its transit segments on top of the shared hop.
This commit is contained in:
Maurice
2026-10-09 14:48:08 +02:00
parent 369697650f
commit 69d2f80fe2
4 changed files with 354 additions and 266 deletions
+10 -132
View File
@@ -2,37 +2,16 @@ import { Fragment, createElement, useMemo, useState } from 'react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import { Plane, Train, Ship, Car, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
import { useSettingsStore } from '../../store/settingsStore'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import type { Reservation, ReservationEndpoint } from '../../types'
import { TRANSPORT_COLOR, TRANSPORT_META, transportHop, type TransportHop, type TransportType } from './transportHops'
import type { Reservation } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints'
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'cable_car' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'cable_car', 'transit', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geodesic: boolean }> = {
flight: { color: TRANSPORT_COLOR, icon: Plane, geodesic: true },
train: { color: TRANSPORT_COLOR, icon: Train, geodesic: false },
cruise: { color: TRANSPORT_COLOR, icon: Ship, geodesic: true },
car: { color: TRANSPORT_COLOR, icon: Car, geodesic: false },
bus: { color: TRANSPORT_COLOR, icon: Bus, geodesic: false },
taxi: { color: TRANSPORT_COLOR, icon: CarTaxiFront, geodesic: false },
bicycle: { color: TRANSPORT_COLOR, icon: Bike, geodesic: false },
ferry: { color: TRANSPORT_COLOR, icon: Sailboat, geodesic: true },
// Valley to mountain station as the rope runs: a straight line, never a road (#2535).
cable_car: { color: TRANSPORT_COLOR, icon: CableCar, geodesic: false },
transit: { color: TRANSPORT_COLOR, icon: TramFront, geodesic: false },
transport_other: { color: TRANSPORT_COLOR, icon: Route, geodesic: false },
}
function useEndpointPane() {
const map = useMap()
useMemo(() => {
@@ -46,7 +25,7 @@ function useEndpointPane() {
}
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
const { icon: IconCmp, color } = TYPE_META[type]
const { icon: IconCmp } = TRANSPORT_META[type]
const svg = renderIconMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
@@ -55,7 +34,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
html: `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px;
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1.5px solid #fff;color:#fff;
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap;
@@ -66,84 +45,14 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
})
}
function toRad(d: number) { return d * Math.PI / 180 }
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
const dLat = toRad(b[0] - a[0])
const dLng = toRad(b[1] - a[1])
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = (timePart || '00:00').split(':').map(Number)
const guess = Date.UTC(y, mo - 1, d, h, mi)
// A malformed date/time (e.g. an imported booking whose time is missing its
// minutes) makes Date.UTC NaN; bail before formatToParts, which throws on a
// non-finite date and would blank the whole trip. computeDuration's finiteness
// check then drops the duration cleanly.
if (!Number.isFinite(guess)) return Number.NaN
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
return guess - (asUtc - guess)
}
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
if (!start || !end) return null
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
if (!start.includes('T') || !end.includes('T')) return null
const fromTz = from.timezone || to.timezone
const toTz = to.timezone || fromTz
let startMs: number, endMs: number
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz)
endMs = parseInTz(end, toTz)
} else {
startMs = new Date(start).getTime()
endMs = new Date(end).getTime()
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
if (endMs <= startMs) endMs += 24 * 60 * 60000
const minutes = Math.round((endMs - startMs) / 60000)
if (minutes <= 0 || minutes > 48 * 60) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
const road = roadRoutes?.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
}
interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
waypoints: ReservationEndpoint[]
type: TransportType
arcs: [number, number][][]
interface TransportItem extends TransportHop {
transitSegs: TransitMapSegment[]
// Route ("VIE → LHR") and duration/distance line. Computed on every update but
// not drawn since the stats badge was dropped; computeDuration still guards the
// non-finite date that used to blank the trip (#1620).
mainLabel: string | null
subLabel: string | null
}
interface Props {
@@ -170,40 +79,9 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
const items = useMemo<TransportItem[]>(() => {
const out: TransportItem[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two,
// so the arc + markers below are byte-identical to before for it.
const waypoints = (r.endpoints || [])
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
if (waypoints.length < 2) continue
const from = waypoints[0]
const to = waypoints[waypoints.length - 1]
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
// One arc per leg (between consecutive waypoints), concatenated.
const arcs: [number, number][][] = []
let distanceKm = 0
for (let i = 0; i < waypoints.length - 1; i++) {
const a = waypoints[i]
const b = waypoints[i + 1]
const segArcs = isGeo
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], true)
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
}
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km`
// Show the full route (FRA → BER → HND) when every waypoint has a code.
const mainLabel = waypoints.every(w => w.code)
? waypoints.map(w => w.code).join(' → ')
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, waypoints, type, arcs, transitSegs: type === 'transit' ? getTransitMapSegments(r) : [], mainLabel, subLabel })
const hop = transportHop(r, true)
if (!hop) continue
out.push({ ...hop, transitSegs: hop.type === 'transit' ? getTransitMapSegments(r) : [] })
}
return out
}, [reservations])
@@ -250,7 +128,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
positions={seg.coords}
pathOptions={seg.walk
? { color: '#64748b', weight: 3, opacity: 0.8, dashArray: '1, 7', lineCap: 'round' }
: { color: seg.color || TYPE_META.transit.color, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
: { color: seg.color || TRANSPORT_COLOR, weight: 3.5, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
/>
</Fragment>
))
@@ -261,7 +139,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
pathOptions={{
color: TYPE_META[item.type].color,
color: TRANSPORT_COLOR,
weight: 2.5,
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
+13 -134
View File
@@ -3,159 +3,38 @@
// react-leaflet is component-driven, mapbox-gl is imperative — so instead of
// a React component, this exports a small manager class the MapViewGL wires
// up next to its other sources/layers. The geometry logic (great-circle arcs,
// antimeridian split, duration math) mirrors the Leaflet overlay so both
// renderers produce the same visual result on the globe or a flat projection.
// duration math) lives in transportHops, shared with the Leaflet overlay, so
// both renderers produce the same visual result on the globe or a flat projection.
import { createElement } from 'react'
import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react'
import { getTransitMapSegments } from './transitGeometry'
import { geodesicArcs } from './flightGeodesy'
import { cleanEndpointName } from './reservationName'
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
import { TRANSPORT_COLOR, TRANSPORT_META, transportHop, type TransportHop, type TransportType } from './transportHops'
import type { Reservation } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations'
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
/** Sits under the coloured transit lines; named here so teardown can find it. */
export const TRANSIT_CASING_LAYER_ID = `${RESERVATION_LINE_LAYER_ID}-transit-casing`
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'cable_car' | 'transit' | 'transport_other'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', 'cable_car', 'transit', 'transport_other']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
flight: { icon: Plane, geodesic: true },
train: { icon: Train, geodesic: false },
cruise: { icon: Ship, geodesic: true },
car: { icon: Car, geodesic: false },
bus: { icon: Bus, geodesic: false },
taxi: { icon: CarTaxiFront, geodesic: false },
bicycle: { icon: Bike, geodesic: false },
ferry: { icon: Sailboat, geodesic: true },
cable_car: { icon: CableCar, geodesic: false },
transit: { icon: TramFront, geodesic: false },
transport_other: { icon: Route, geodesic: false },
}
// ── geometry helpers (shared with ReservationOverlay via flightGeodesy) ──
const toRad = (d: number) => d * Math.PI / 180
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
const dLat = toRad(b[0] - a[0])
const dLng = toRad(b[1] - a[1])
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = (timePart || '00:00').split(':').map(Number)
const guess = Date.UTC(y, mo - 1, d, h, mi)
// A malformed date/time (e.g. an imported booking whose time is missing its
// minutes) makes Date.UTC NaN; bail before formatToParts, which throws on a
// non-finite date and would blank the whole trip. computeDuration's finiteness
// check then drops the duration cleanly.
if (!Number.isFinite(guess)) return Number.NaN
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
return guess - (asUtc - guess)
}
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
if (!start || !end) return null
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
if (!start.includes('T') || !end.includes('T')) return null
const fromTz = from.timezone || to.timezone
const toTz = to.timezone || fromTz
let startMs: number, endMs: number
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz)
endMs = parseInTz(end, toTz)
} else {
startMs = new Date(start).getTime()
endMs = new Date(end).getTime()
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
if (endMs <= startMs) endMs += 24 * 60 * 60000
const minutes = Math.round((endMs - startMs) / 60000)
if (minutes <= 0 || minutes > 48 * 60) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
// ── item building ─────────────────────────────────────────────────────────
interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
waypoints: ReservationEndpoint[]
type: TransportType
arcs: [number, number][][]
// Route ("VIE → LHR") and duration/distance line. Computed on every update but
// not drawn since the stats badge was dropped; computeDuration still guards the
// non-finite date that used to blank the trip (#1620).
mainLabel: string | null
subLabel: string | null
}
function buildItems(reservations: Reservation[]): TransportItem[] {
const out: TransportItem[] = []
// What each booking draws comes from transportHops. GL maps repeat features
// across world copies themselves, so arcs are not wrapped at the antimeridian.
function buildItems(reservations: Reservation[]): TransportHop[] {
const out: TransportHop[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
// Ordered waypoints (from · stops · to); a single-leg booking has exactly two.
const waypoints = (r.endpoints || [])
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
if (waypoints.length < 2) continue
const from = waypoints[0]
const to = waypoints[waypoints.length - 1]
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
// One arc per leg (between consecutive waypoints), concatenated.
const arcs: [number, number][][] = []
let distanceKm = 0
for (let i = 0; i < waypoints.length - 1; i++) {
const a = waypoints[i]
const b = waypoints[i + 1]
const segArcs = isGeo
// GL maps repeat features across world copies themselves, so one
// continuous unwrapped arc is enough (a shifted duplicate would
// coincide with the wrapped copy and double the line opacity).
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], false)
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
arcs.push(...segArcs)
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
}
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(distanceKm)} km`
const mainLabel = waypoints.every(w => w.code)
? waypoints.map(w => w.code).join(' → ')
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, waypoints, type, arcs, mainLabel, subLabel })
const hop = transportHop(r, false)
if (hop) out.push(hop)
}
return out
}
// ── DOM helpers for HTML markers ──────────────────────────────────────────
function endpointMarkerHtml(type: TransportType, label: string | null): string {
const { icon: IconCmp } = TYPE_META[type]
const { icon: IconCmp } = TRANSPORT_META[type]
const svg = renderIconMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''
return `<div style="
@@ -189,7 +68,7 @@ type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string
export class ReservationMapboxOverlay {
private map: mapboxgl.Map
private items: TransportItem[] = []
private items: TransportHop[] = []
private roadRoutes: Map<number, [number, number][]> = new Map()
private opts: ReservationOverlayOptions
private MarkerCtor: MarkerConstructor
@@ -262,7 +141,7 @@ export class ReservationMapboxOverlay {
}
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
private linesFor(item: TransportItem): [number, number][][] {
private linesFor(item: TransportHop): [number, number][][] {
const road = this.roadRoutes.get(item.res.id)
return road && road.length >= 2 ? [road] : item.arcs
}
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import type { Reservation, ReservationEndpoint } from '../../types';
import { TRANSPORT_META, transportHop } from './transportHops';
function endpoint(over: Partial<ReservationEndpoint>): ReservationEndpoint {
return {
role: 'from',
sequence: 0,
name: 'X',
code: null,
lat: 0,
lng: 0,
timezone: null,
local_time: null,
local_date: null,
...over,
} as ReservationEndpoint;
}
function booking(type: string, endpoints: ReservationEndpoint[], over: Partial<Reservation> = {}): Reservation {
return { id: 7, type, status: 'confirmed', endpoints, ...over } as unknown as Reservation;
}
describe('transportHop', () => {
it('skips a booking that is no transport', () => {
const r = booking('hotel', [endpoint({ role: 'from' }), endpoint({ role: 'to', sequence: 1 })]);
expect(transportHop(r, true)).toBeNull();
});
it('skips a transport with fewer than two places to connect', () => {
expect(transportHop(booking('train', [endpoint({ role: 'from' })]), true)).toBeNull();
expect(
transportHop(booking('train', [endpoint({ role: 'from' }), endpoint({ role: 'check_in' as never })]), true)
).toBeNull();
});
it('orders from, stops and to by sequence and draws one straight leg each for ground transport', () => {
const r = booking('train', [
endpoint({ role: 'to', sequence: 2, code: 'HND', lat: 3, lng: 3 }),
endpoint({ role: 'from', sequence: 0, code: 'FRA', lat: 1, lng: 1 }),
endpoint({ role: 'stop', sequence: 1, code: 'BER', lat: 2, lng: 2 }),
]);
const hop = transportHop(r, true)!;
expect(hop.waypoints.map((w) => w.code)).toEqual(['FRA', 'BER', 'HND']);
expect(hop.from.code).toBe('FRA');
expect(hop.to.code).toBe('HND');
expect(hop.arcs).toEqual([
[
[1, 1],
[2, 2],
],
[
[2, 2],
[3, 3],
],
]);
expect(hop.mainLabel).toBe('FRA → BER → HND');
expect(hop.res).toBe(r);
expect(hop.type).toBe('train');
});
it('falls back to the two end codes when a stop has none, and to no label without them', () => {
const withEnds = booking('bus', [
endpoint({ role: 'from', code: 'A' }),
endpoint({ role: 'stop', sequence: 1 }),
endpoint({ role: 'to', sequence: 2, code: 'B' }),
]);
expect(transportHop(withEnds, true)!.mainLabel).toBe('A → B');
const bare = booking('bus', [endpoint({ role: 'from' }), endpoint({ role: 'to', sequence: 1 })]);
expect(transportHop(bare, true)!.mainLabel).toBeNull();
});
it('puts duration and great-circle distance in the sub label', () => {
const r = booking('car', [
endpoint({ role: 'from', lat: 0, lng: 0, local_date: '2026-05-01', local_time: '08:00' }),
endpoint({ role: 'to', sequence: 1, lat: 0, lng: 1, local_date: '2026-05-01', local_time: '09:30' }),
]);
expect(transportHop(r, true)!.subLabel).toBe('1h 30m · 111 km');
});
it('copies a flight arc across the antimeridian only when asked to wrap', () => {
const r = booking('flight', [
endpoint({ role: 'from', lat: 35, lng: 170 }),
endpoint({ role: 'to', sequence: 1, lat: 40, lng: -170 }),
]);
expect(transportHop(r, true)!.arcs).toHaveLength(2);
expect(transportHop(r, false)!.arcs).toHaveLength(1);
expect(transportHop(r, false)!.arcs[0].length).toBeGreaterThan(2);
});
it('draws ferries and cruises as arcs but a cable car as a straight line', () => {
expect(TRANSPORT_META.ferry.geodesic).toBe(true);
expect(TRANSPORT_META.cruise.geodesic).toBe(true);
expect(TRANSPORT_META.cable_car.geodesic).toBe(false);
});
});
describe('transportHop duration', () => {
// Both ends sit on the same spot, so the sub label is the duration plus "0 km".
const at = (date: string | null, time: string | null, timezone: string | null = null) =>
endpoint({ local_date: date, local_time: time, timezone });
const subLabel = (
from: ReservationEndpoint,
to: ReservationEndpoint,
start: string | null = null,
end: string | null = null
) =>
transportHop(
booking(
'train',
[
{ ...from, role: 'from' },
{ ...to, role: 'to', sequence: 1 },
],
{
reservation_time: start,
reservation_end_time: end,
}
),
true
)!.subLabel;
it('reads each end in its own time zone', () => {
expect(subLabel(at('2026-05-01', '10:00', 'Europe/Berlin'), at('2026-05-01', '10:00', 'Europe/London'))).toBe(
'1h 0m · 0 km'
);
});
it('rolls an arrival before the departure over to the next day', () => {
expect(subLabel(at('2026-05-01', '23:30'), at('2026-05-01', '00:15'))).toBe('45m · 0 km');
});
it('borrows the date of the other end for a bare time from the booking', () => {
expect(subLabel(at(null, null), at('2026-05-01', '12:00'), '11:00')).toBe('1h 0m · 0 km');
});
it('leaves the duration out for missing, malformed or implausible times', () => {
expect(subLabel(at(null, null), at(null, null))).toBe('0 km');
expect(subLabel(at(null, null), at(null, null), '10:00', '11:00')).toBe('0 km');
expect(subLabel(at('2026-05-01', '10', 'Europe/Berlin'), at('2026-05-01', '11:00', 'Europe/Berlin'))).toBe('0 km');
expect(subLabel(at('2026-05-01', '10:00'), at('2026-05-04', '10:00'))).toBe('0 km');
});
});
+188
View File
@@ -0,0 +1,188 @@
// What a transport booking draws on the map, worked out once for both renderers.
//
// ReservationOverlay (Leaflet) and reservationsMapbox (GL) used to carry their own
// copy of this: which booking types count, the great-circle arcs per leg and the
// route/duration labels. The drawing itself stays in each renderer.
import { Bike, Bus, CableCar, Car, CarTaxiFront, Plane, Route, Sailboat, Ship, Train, TramFront } from 'lucide-react';
import type { Reservation, ReservationEndpoint } from '../../types';
import { haversineKm } from '../../utils/geo';
import { geodesicArcs } from './flightGeodesy';
export type TransportType =
| 'flight'
| 'train'
| 'cruise'
| 'car'
| 'bus'
| 'taxi'
| 'bicycle'
| 'ferry'
| 'cable_car'
| 'transit'
| 'transport_other';
const TRANSPORT_TYPES: TransportType[] = [
'flight',
'train',
'cruise',
'car',
'bus',
'taxi',
'bicycle',
'ferry',
'cable_car',
'transit',
'transport_other',
];
export const TRANSPORT_COLOR = '#3b82f6';
export const TRANSPORT_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
flight: { icon: Plane, geodesic: true },
train: { icon: Train, geodesic: false },
cruise: { icon: Ship, geodesic: true },
car: { icon: Car, geodesic: false },
bus: { icon: Bus, geodesic: false },
taxi: { icon: CarTaxiFront, geodesic: false },
bicycle: { icon: Bike, geodesic: false },
ferry: { icon: Sailboat, geodesic: true },
// Valley to mountain station as the rope runs: a straight line, never a road (#2535).
cable_car: { icon: CableCar, geodesic: false },
transit: { icon: TramFront, geodesic: false },
transport_other: { icon: Route, geodesic: false },
};
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T');
const [y, mo, d] = datePart.split('-').map(Number);
const [h, mi] = (timePart || '00:00').split(':').map(Number);
const guess = Date.UTC(y, mo - 1, d, h, mi);
// A malformed date/time (e.g. an imported booking whose time is missing its
// minutes) makes Date.UTC NaN; bail before formatToParts, which throws on a
// non-finite date and would blank the whole trip. computeDuration's finiteness
// check then drops the duration cleanly.
if (!Number.isFinite(guess)) return Number.NaN;
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = Object.fromEntries(
fmt
.formatToParts(new Date(guess))
.filter((p) => p.type !== 'literal')
.map((p) => [p.type, p.value])
);
const asUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour) % 24,
Number(parts.minute),
Number(parts.second)
);
return guess - (asUtc - guess);
}
/** "2h 5m" between the two ends, read in their own time zones; null when it cannot be told. */
function computeDuration(
from: ReservationEndpoint,
to: ReservationEndpoint,
fallbackStart: string | null,
fallbackEnd: string | null
): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart;
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd;
if (!start || !end) return null;
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`;
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`;
if (!start.includes('T') || !end.includes('T')) return null;
const fromTz = from.timezone || to.timezone;
const toTz = to.timezone || fromTz;
let startMs: number, endMs: number;
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz);
endMs = parseInTz(end, toTz);
} else {
startMs = new Date(start).getTime();
endMs = new Date(end).getTime();
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
if (endMs <= startMs) endMs += 24 * 60 * 60000;
const minutes = Math.round((endMs - startMs) / 60000);
if (minutes <= 0 || minutes > 48 * 60) return null;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
export interface TransportHop {
res: Reservation;
from: ReservationEndpoint;
to: ReservationEndpoint;
waypoints: ReservationEndpoint[];
type: TransportType;
arcs: [number, number][][];
// Route ("VIE → LHR") and duration/distance line. Computed on every update but
// not drawn since the stats badge was dropped; computeDuration still guards the
// non-finite date that used to blank the trip (#1620).
mainLabel: string | null;
subLabel: string | null;
}
/**
* The hop a booking draws, or null when it is no transport or has fewer than two
* places to connect. wrapArcs says whether a great-circle arc that crosses the
* antimeridian also gets a copy shifted by 360 degrees: Leaflet needs that, while a
* GL map repeats features across world copies itself, so there one continuous arc
* is enough (a shifted duplicate would sit on the wrapped copy and double the opacity).
*/
export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop | null {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) return null;
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two.
const waypoints = (r.endpoints || [])
.filter((e) => e.role === 'from' || e.role === 'to' || e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
if (waypoints.length < 2) return null;
const from = waypoints[0];
const to = waypoints[waypoints.length - 1];
const type = r.type as TransportType;
const isGeo = TRANSPORT_META[type].geodesic;
// One arc per leg (between consecutive waypoints), concatenated.
const arcs: [number, number][][] = [];
let distanceKm = 0;
for (let i = 0; i < waypoints.length - 1; i++) {
const a = waypoints[i];
const b = waypoints[i + 1];
const segArcs = isGeo
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs)
: [
[
[a.lat, a.lng],
[b.lat, b.lng],
] as [number, number][],
];
arcs.push(...segArcs);
distanceKm += haversineKm(a, b);
}
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null);
const distance = `${Math.round(distanceKm)} km`;
// Show the full route (FRA → BER → HND) when every waypoint has a code.
const mainLabel = waypoints.every((w) => w.code)
? waypoints.map((w) => w.code).join(' → ')
: from.code && to.code
? `${from.code} → ${to.code}`
: null;
const subParts = [duration, distance].filter(Boolean) as string[];
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null;
return { res: r, from, to, waypoints, type, arcs, mainLabel, subLabel };
}