diff --git a/client/src/components/Map/ReservationOverlay.tsx b/client/src/components/Map/ReservationOverlay.tsx index 7df978ad6..c1170cbd3 100644 --- a/client/src/components/Map/ReservationOverlay.tsx +++ b/client/src/components/Map/ReservationOverlay.tsx @@ -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 = { - 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 ? `${escapeHtml(label)}` : '' 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: `
(() => { 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' }} /> )) @@ -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', diff --git a/client/src/components/Map/reservationsMapbox.ts b/client/src/components/Map/reservationsMapbox.ts index ad6becc04..81bb8c9b8 100644 --- a/client/src/components/Map/reservationsMapbox.ts +++ b/client/src/components/Map/reservationsMapbox.ts @@ -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 = { - 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 ? `${escapeHtml(label)}` : '' return `
+ 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'); + }); +}); diff --git a/client/src/components/Map/transportHops.ts b/client/src/components/Map/transportHops.ts new file mode 100644 index 000000000..87949e538 --- /dev/null +++ b/client/src/components/Map/transportHops.ts @@ -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 = { + 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 }; +}