mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
bb477645a3
* feat(transport): support multi-leg (layover) flights in the booking form
A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.
Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.
* feat(planner): show each flight leg as its own day-plan entry, ordered by time
A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.
Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.
* feat(planner): show the full multi-stop route in the bookings panel
The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.
* feat(map): draw multi-leg flights as connected legs with a marker per airport
Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.
Also drops the floating stats badge that was drawn on transport arcs.
* fix(map): centre a clicked place above the bottom inspector panel
Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).
* feat: show the full multi-stop flight route in PDF and calendar export
The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.
* feat(import): group connecting flight legs into one multi-leg booking
When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.
* i18n: translate the new flight-route strings into all languages
* i18n: translate the Costs page into every language
The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.
* Revert "fix(map): centre a clicked place above the bottom inspector panel"
This reverts commit 0936103f04.
392 lines
17 KiB
TypeScript
392 lines
17 KiB
TypeScript
// Mapbox GL counterpart to ReservationOverlay.tsx.
|
|
//
|
|
// 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.
|
|
|
|
import { createElement } from 'react'
|
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import mapboxgl from 'mapbox-gl'
|
|
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
|
import { escapeHtml } from '@trek/shared'
|
|
import type { Reservation, ReservationEndpoint } from '../../types'
|
|
|
|
export const RESERVATION_SOURCE_ID = 'trek-reservations'
|
|
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
|
|
|
|
type TransportType = 'flight' | 'train' | 'cruise' | 'car' | 'bus' | 'taxi' | 'bicycle' | 'ferry' | 'transport_other'
|
|
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car', 'bus', 'taxi', 'bicycle', 'ferry', '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 },
|
|
transport_other: { icon: Route, geodesic: false },
|
|
}
|
|
|
|
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
|
|
const toRad = (d: number) => d * Math.PI / 180
|
|
const toDeg = (r: number) => r * 180 / Math.PI
|
|
|
|
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
|
|
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
|
|
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
|
|
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
|
|
if (d === 0) return [a, b]
|
|
const pts: [number, number][] = []
|
|
for (let i = 0; i <= steps; i++) {
|
|
const f = i / steps
|
|
const A = Math.sin((1 - f) * d) / Math.sin(d)
|
|
const B = Math.sin(f * d) / Math.sin(d)
|
|
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
|
|
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
|
|
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
|
|
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
|
|
const lng = Math.atan2(y, x)
|
|
pts.push([toDeg(lat), toDeg(lng)])
|
|
}
|
|
return pts
|
|
}
|
|
|
|
function splitAntimeridian(points: [number, number][]): [number, number][][] {
|
|
const segments: [number, number][][] = []
|
|
let cur: [number, number][] = []
|
|
for (let i = 0; i < points.length; i++) {
|
|
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
|
|
if (cur.length > 1) segments.push(cur)
|
|
cur = []
|
|
}
|
|
cur.push(points[i])
|
|
}
|
|
if (cur.length > 1) segments.push(cur)
|
|
return segments
|
|
}
|
|
|
|
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)
|
|
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`
|
|
}
|
|
|
|
const cleanName = (name: string) => name.replace(/\s*\([^)]*\)/g, '').trim()
|
|
|
|
// ── item building ─────────────────────────────────────────────────────────
|
|
interface TransportItem {
|
|
res: Reservation
|
|
from: ReservationEndpoint
|
|
to: ReservationEndpoint
|
|
waypoints: ReservationEndpoint[]
|
|
type: TransportType
|
|
arcs: [number, number][][]
|
|
primaryArc: [number, number][]
|
|
mainLabel: string | null
|
|
subLabel: string | null
|
|
}
|
|
|
|
function buildItems(reservations: Reservation[]): 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.
|
|
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
|
|
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
|
: [[[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 primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
|
const primaryArc = arcs[primaryIdx] ?? []
|
|
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, primaryArc, mainLabel, subLabel })
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── DOM helpers for HTML markers ──────────────────────────────────────────
|
|
function endpointMarkerHtml(type: TransportType, label: string | null): string {
|
|
const { icon: IconCmp } = TYPE_META[type]
|
|
const svg = renderToStaticMarkup(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="
|
|
display:inline-flex;align-items:center;justify-content:center;gap:4px;
|
|
padding:0 8px;border-radius:999px;
|
|
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;cursor:pointer;
|
|
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
|
|
}
|
|
|
|
function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
|
|
const estWidth = Math.max(
|
|
mainLabel ? mainLabel.length * 6.5 : 0,
|
|
subLabel ? subLabel.length * 5.5 : 0,
|
|
) + 22
|
|
const hasBoth = !!mainLabel && !!subLabel
|
|
const height = hasBoth ? 36 : 22
|
|
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
|
|
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
|
|
const html = `<div class="trek-stats-inner" style="
|
|
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
width:100%;height:100%;
|
|
padding:0 11px;border-radius:999px;
|
|
background:rgba(17,24,39,0.92);color:#fff;
|
|
box-shadow:0 2px 6px rgba(0,0,0,0.25);
|
|
border:1px solid ${TRANSPORT_COLOR}aa;
|
|
font-family:var(--font-system);
|
|
white-space:nowrap;box-sizing:border-box;pointer-events:none;
|
|
transform-origin:center;will-change:transform;
|
|
">${main}${sub}</div>`
|
|
return { html, width: estWidth, height }
|
|
}
|
|
|
|
// ── overlay manager ──────────────────────────────────────────────────────
|
|
export interface ReservationOverlayOptions {
|
|
showConnections: boolean
|
|
showStats: boolean
|
|
showEndpointLabels: boolean
|
|
onEndpointClick?: (reservationId: number) => void
|
|
}
|
|
|
|
export class ReservationMapboxOverlay {
|
|
private map: mapboxgl.Map
|
|
private items: TransportItem[] = []
|
|
private opts: ReservationOverlayOptions
|
|
private endpointMarkers: mapboxgl.Marker[] = []
|
|
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
|
|
private rerender: () => void
|
|
private destroyed = false
|
|
|
|
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
|
|
this.map = map
|
|
this.opts = opts
|
|
this.rerender = () => { if (!this.destroyed) this.render() }
|
|
this.setupLayer()
|
|
map.on('zoomend', this.rerender)
|
|
map.on('moveend', this.rerender)
|
|
map.on('render', this.updateStatsRotation)
|
|
}
|
|
|
|
update(reservations: Reservation[], opts: ReservationOverlayOptions) {
|
|
this.opts = opts
|
|
this.items = buildItems(reservations)
|
|
this.render()
|
|
}
|
|
|
|
destroy() {
|
|
this.destroyed = true
|
|
this.map.off('zoomend', this.rerender)
|
|
this.map.off('moveend', this.rerender)
|
|
this.map.off('render', this.updateStatsRotation)
|
|
this.endpointMarkers.forEach(m => m.remove())
|
|
this.endpointMarkers = []
|
|
this.statsMarkers.forEach(s => s.marker.remove())
|
|
this.statsMarkers = []
|
|
try {
|
|
if (this.map.getLayer(RESERVATION_LINE_LAYER_ID)) this.map.removeLayer(RESERVATION_LINE_LAYER_ID)
|
|
if (this.map.getSource(RESERVATION_SOURCE_ID)) this.map.removeSource(RESERVATION_SOURCE_ID)
|
|
} catch { /* map already gone */ }
|
|
}
|
|
|
|
private setupLayer() {
|
|
const map = this.map
|
|
if (map.getSource(RESERVATION_SOURCE_ID)) return
|
|
map.addSource(RESERVATION_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
map.addLayer({
|
|
id: RESERVATION_LINE_LAYER_ID,
|
|
type: 'line',
|
|
source: RESERVATION_SOURCE_ID,
|
|
paint: {
|
|
'line-color': TRANSPORT_COLOR,
|
|
'line-width': 2.5,
|
|
// Confirmed = solid + 0.75; pending = dashed + 0.55.
|
|
'line-opacity': ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55] as any,
|
|
'line-dasharray': ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]] as any,
|
|
},
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
}
|
|
|
|
private render() {
|
|
const map = this.map
|
|
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
|
|
|
|
const show = this.opts.showConnections
|
|
|
|
// Visible filter: require the on-screen pixel distance between
|
|
// endpoints to exceed a type-specific minimum, same as the Leaflet
|
|
// overlay, so tiny no-op transport lines don't clutter the map.
|
|
const visibleItems = show ? this.items.filter(item => {
|
|
try {
|
|
const fromPx = map.project([item.from.lng, item.from.lat])
|
|
const toPx = map.project([item.to.lng, item.to.lat])
|
|
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
|
return dist >= minPx
|
|
} catch { return true }
|
|
}) : []
|
|
|
|
// Label visibility threshold is higher than line visibility, to keep
|
|
// endpoint text from overlapping on very short lines.
|
|
const labelVisibleIds = new Set<number>()
|
|
if (show) {
|
|
for (const item of visibleItems) {
|
|
try {
|
|
const fromPx = map.project([item.from.lng, item.from.lat])
|
|
const toPx = map.project([item.to.lng, item.to.lat])
|
|
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
|
|
if (dist >= minPx) labelVisibleIds.add(item.res.id)
|
|
} catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
// ── line features ───────────────────────────────────────────────
|
|
const features = visibleItems.flatMap(item => item.arcs.map(seg => ({
|
|
type: 'Feature' as const,
|
|
properties: {
|
|
resId: item.res.id,
|
|
type: item.type,
|
|
status: item.res.status ?? 'pending',
|
|
},
|
|
geometry: {
|
|
type: 'LineString' as const,
|
|
coordinates: seg.map(([lat, lng]) => [lng, lat]),
|
|
},
|
|
})))
|
|
const src = map.getSource(RESERVATION_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
|
|
src?.setData({ type: 'FeatureCollection', features })
|
|
|
|
// ── endpoint markers ────────────────────────────────────────────
|
|
this.endpointMarkers.forEach(m => m.remove())
|
|
this.endpointMarkers = []
|
|
if (show) {
|
|
for (const item of visibleItems) {
|
|
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
|
|
for (const ep of item.waypoints) {
|
|
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
|
|
const el = document.createElement('div')
|
|
el.innerHTML = endpointMarkerHtml(item.type, label)
|
|
const inner = el.firstElementChild as HTMLElement | null
|
|
const node = inner ?? el
|
|
node.title = ep.name || ''
|
|
if (this.opts.onEndpointClick) {
|
|
node.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
this.opts.onEndpointClick?.(item.res.id)
|
|
})
|
|
}
|
|
const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
|
|
.setLngLat([ep.lng, ep.lat])
|
|
.addTo(map)
|
|
this.endpointMarkers.push(marker)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stats badge removed — the floating route/duration label on the arc is no
|
|
// longer drawn; only the connection line and the airport markers remain.
|
|
this.statsMarkers.forEach(s => s.marker.remove())
|
|
this.statsMarkers = []
|
|
}
|
|
|
|
// Match the Leaflet overlay's "rotate the label along the arc" look.
|
|
// We pick a short segment straddling the arc midpoint, measure the
|
|
// screen angle between those two projected points, and clamp it to
|
|
// [-90°, 90°] so text never renders upside-down.
|
|
private updateStatsRotation = () => {
|
|
if (this.destroyed) return
|
|
for (const entry of this.statsMarkers) {
|
|
const { marker, arc } = entry
|
|
if (arc.length < 2) continue
|
|
const midIdx = Math.floor(arc.length / 2)
|
|
const a = arc[Math.max(0, midIdx - 2)]!
|
|
const b = arc[Math.min(arc.length - 1, midIdx + 2)]!
|
|
try {
|
|
const pa = this.map.project([a[1], a[0]])
|
|
const pb = this.map.project([b[1], b[0]])
|
|
let angle = Math.atan2(pb.y - pa.y, pb.x - pa.x) * 180 / Math.PI
|
|
if (angle > 90) angle -= 180
|
|
if (angle < -90) angle += 180
|
|
const el = marker.getElement()
|
|
const inner = el.querySelector('.trek-stats-inner') as HTMLElement | null
|
|
if (inner) inner.style.transform = `rotate(${angle}deg)`
|
|
} catch { /* map not ready / projection failure */ }
|
|
}
|
|
}
|
|
}
|