mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user