From 6ec452917a7f208281f4273f7dd633b937152371 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 11 Jun 2026 15:39:06 +0200 Subject: [PATCH] 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. --- .../src/components/Map/ReservationOverlay.tsx | 69 ++++++++++--------- .../src/components/Map/reservationsMapbox.ts | 61 ++++++++-------- 2 files changed, 64 insertions(+), 66 deletions(-) diff --git a/client/src/components/Map/ReservationOverlay.tsx b/client/src/components/Map/ReservationOverlay.tsx index 8db225f8..295dd7c6 100644 --- a/client/src/components/Map/ReservationOverlay.tsx +++ b/client/src/components/Map/ReservationOverlay.tsx @@ -158,6 +158,7 @@ interface TransportItem { res: Reservation from: ReservationEndpoint to: ReservationEndpoint + waypoints: ReservationEndpoint[] type: TransportType arcs: [number, number][][] primaryArc: [number, number][] @@ -353,15 +354,29 @@ export default function ReservationOverlay({ reservations, showConnections, show const out: TransportItem[] = [] for (const r of reservations) { if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue - const eps = r.endpoints || [] - const from = eps.find(e => e.role === 'from') - const to = eps.find(e => e.role === 'to') - if (!from || !to) 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 - const arcs = isGeo - ? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng])) - : [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]] + // 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 fallback: [number, number] = primaryArc.length > 0 @@ -369,12 +384,15 @@ export default function ReservationOverlay({ reservations, showConnections, show : [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2] const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null) - const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km` - const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : 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, type, arcs, primaryArc, fallback, mainLabel, subLabel }) + out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel }) } return out }, [reservations]) @@ -416,38 +434,21 @@ export default function ReservationOverlay({ reservations, showConnections, show /> )))} - {visibleItems.flatMap(item => [ + {visibleItems.flatMap(item => item.waypoints.map((wp, wi) => ( onEndpointClick?.(item.res.id) }} > -
{item.from.name}
+
{wp.name}
{item.res.title &&
{item.res.title}
}
-
, - onEndpointClick?.(item.res.id) }} - > - -
{item.to.name}
- {item.res.title &&
{item.res.title}
} -
-
, - ])} - - {showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && ( - - ))} + + )))} ) } diff --git a/client/src/components/Map/reservationsMapbox.ts b/client/src/components/Map/reservationsMapbox.ts index 2c912b91..1722690c 100644 --- a/client/src/components/Map/reservationsMapbox.ts +++ b/client/src/components/Map/reservationsMapbox.ts @@ -126,6 +126,7 @@ interface TransportItem { res: Reservation from: ReservationEndpoint to: ReservationEndpoint + waypoints: ReservationEndpoint[] type: TransportType arcs: [number, number][][] primaryArc: [number, number][] @@ -137,23 +138,38 @@ function buildItems(reservations: Reservation[]): TransportItem[] { const out: TransportItem[] = [] for (const r of reservations) { if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue - const eps = r.endpoints || [] - const from = eps.find(e => e.role === 'from') - const to = eps.find(e => e.role === 'to') - if (!from || !to) 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 - const arcs = isGeo - ? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng])) - : [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]] + // 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(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km` - const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : 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, type, arcs, primaryArc, mainLabel, subLabel }) + out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, mainLabel, subLabel }) } return out } @@ -321,7 +337,7 @@ export class ReservationMapboxOverlay { if (show) { for (const item of visibleItems) { const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id) - for (const ep of [item.from, item.to]) { + 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) @@ -342,29 +358,10 @@ export class ReservationMapboxOverlay { } } - // ── stats label (flights only) ────────────────────────────────── + // 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 = [] - if (show && this.opts.showStats) { - for (const item of visibleItems) { - if (item.type !== 'flight') continue - if (!labelVisibleIds.has(item.res.id)) continue - if (!item.mainLabel && !item.subLabel) continue - const arc = item.primaryArc - if (arc.length < 2) continue - const mid = arc[Math.floor(arc.length / 2)]! - const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel) - const el = document.createElement('div') - el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;` - el.innerHTML = html - const marker = new mapboxgl.Marker({ element: el, anchor: 'center' }) - .setLngLat([mid[1], mid[0]]) - .addTo(map) - this.statsMarkers.push({ marker, arc }) - } - } - // Prime rotation once so labels don't flash horizontal on first paint. - this.updateStatsRotation() } // Match the Leaflet overlay's "rotate the label along the arc" look.