import { createElement, useEffect, useMemo, useRef, useState } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet' import L from 'leaflet' import { Plane, Train, Ship, Car } from 'lucide-react' import { useSettingsStore } from '../../store/settingsStore' import type { Reservation, ReservationEndpoint } from '../../types' const ENDPOINT_PANE = 'reservation-endpoints' const AIRPORT_BADGE_HALF_PX = 16 const BADGE_GAP_PX = 5 type TransportType = 'flight' | 'train' | 'cruise' | 'car' const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car'] 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 }, } function useEndpointPane() { const map = useMap() useMemo(() => { if (typeof map?.getPane !== 'function' || typeof map?.createPane !== 'function') return if (!map.getPane(ENDPOINT_PANE)) { const pane = map.createPane(ENDPOINT_PANE) pane.style.zIndex = '650' pane.style.pointerEvents = 'auto' } }, [map]) } function endpointIcon(type: TransportType, label: string | null): L.DivIcon { const { icon: IconCmp, color } = TYPE_META[type] const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 })) const labelHtml = label ? `${label}` : '' const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26 return L.divIcon({ className: 'trek-endpoint-marker', html: `
${svg}${labelHtml ? `${label}` : ''}
`, iconSize: [estWidth, 22], iconAnchor: [estWidth / 2, 11], popupAnchor: [0, -11], }) } function toRad(d: number) { return d * Math.PI / 180 } function toDeg(r: number) { return 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 cleanName(name: string): string { return name.replace(/\s*\([^)]*\)/g, '').trim() } 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` } interface TransportItem { res: Reservation from: ReservationEndpoint to: ReservationEndpoint type: TransportType arcs: [number, number][][] primaryArc: [number, number][] fallback: [number, number] mainLabel: string | null subLabel: string | null } function buildStatsHtml(color: string, 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 ? `${mainLabel}` : '' const sub = subLabel ? `${subLabel}` : '' const html = `
${main}${sub}
` return { html, width: estWidth, height } } function StatsLabel({ item }: { item: TransportItem }) { const map = useMap() const markerRef = useRef(null) const innerRef = useRef(null) const arc = item.primaryArc const color = TYPE_META[item.type].color const { html, width, height } = useMemo(() => buildStatsHtml(color, item.mainLabel, item.subLabel), [color, item.mainLabel, item.subLabel]) const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX const compute = () => { if (arc.length < 2) return null const size = map.getSize() const pts = arc.map(p => map.latLngToContainerPoint(p as L.LatLngTuple)) const cum: number[] = [0] let total = 0 for (let i = 1; i < pts.length; i++) { total += pts[i].distanceTo(pts[i - 1]) cum.push(total) } if (total <= 0) return null const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng]) const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng]) const isIn = (p: L.Point) => { if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false if (p.distanceTo(fromPx) < buffer) return false if (p.distanceTo(toPx) < buffer) return false return true } let firstIdx = -1 let lastIdx = -1 for (let i = 0; i < pts.length; i++) { if (isIn(pts[i])) { if (firstIdx < 0) firstIdx = i lastIdx = i } } if (firstIdx < 0) { const target = total / 2 let sIdx = 0 while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++ const span = cum[sIdx + 1] - cum[sIdx] const tm = span > 0 ? (target - cum[sIdx]) / span : 0 const pA = pts[sIdx] const pB = pts[sIdx + 1] const mx = pA.x + (pB.x - pA.x) * tm const my = pA.y + (pB.y - pA.y) * tm const latlng = map.containerPointToLatLng([mx, my]) 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 return { point: [latlng.lat, latlng.lng] as [number, number], angle } } const bisectFraction = (a: L.Point, b: L.Point) => { let lo = 0, hi = 1 for (let k = 0; k < 10; k++) { const mid = (lo + hi) / 2 const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid) if (isIn(mp)) hi = mid else lo = mid } return (lo + hi) / 2 } let lowCum = cum[firstIdx] if (firstIdx > 0) { const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx]) lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t } let highCum = cum[lastIdx] if (lastIdx < pts.length - 1) { const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx]) highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t) } const targetLen = (lowCum + highCum) / 2 let segIdx = 0 while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++ const segSpan = cum[segIdx + 1] - cum[segIdx] const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0 const pA = pts[segIdx] const pB = pts[segIdx + 1] const px = pA.x + (pB.x - pA.x) * t const py = pA.y + (pB.y - pA.y) * t const latlng = map.containerPointToLatLng([px, py]) 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 return { point: [latlng.lat, latlng.lng] as [number, number], angle } } const apply = () => { const pose = compute() const marker = markerRef.current if (!marker) return const el = marker.getElement() as HTMLElement | null if (!pose) { if (el) el.style.display = 'none' return } if (el) el.style.display = '' marker.setLatLng(pose.point as L.LatLngTuple) if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)` } useEffect(() => { const icon = L.divIcon({ className: 'trek-endpoint-stats', html, iconSize: [width, height], iconAnchor: [width / 2, height / 2], }) const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false }) marker.addTo(map) markerRef.current = marker innerRef.current = null apply() return () => { marker.remove() markerRef.current = null innerRef.current = null } }, [map, html, width, height]) useMapEvents({ move: apply, zoom: apply, viewreset: apply, resize: apply, }) return null } interface Props { reservations: Reservation[] showConnections: boolean showStats: boolean onEndpointClick?: (reservationId: number) => void } export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) { useEndpointPane() const map = useMap() const [zoom, setZoom] = useState(() => map.getZoom()) useMapEvents({ zoomend: () => setZoom(map.getZoom()), }) const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false const items = useMemo(() => { 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 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][]] 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 ? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]) : [(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 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 }) } return out }, [reservations]) const visibleItems = useMemo(() => { return items.filter(item => { const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng]) const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng]) const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200 return fromPx.distanceTo(toPx) >= minPx }) }, [items, zoom, map]) const labelVisibleIds = useMemo(() => { const set = new Set() for (const item of visibleItems) { const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng]) const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng]) const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400 if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id) } return set }, [visibleItems, zoom, map]) if (!showConnections) return null return ( <> {visibleItems.map(item => item.arcs.map((seg, segIdx) => ( )))} {visibleItems.flatMap(item => [ onEndpointClick?.(item.res.id) }} >
{item.from.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) && ( ))} ) }