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.