From 634210f2b12cd4c8bb2d51a85fd36288803558b1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 4 Jul 2026 22:26:25 +0200 Subject: [PATCH] fix(map): render date-line-crossing routes as one continuous arc (#1411) The great-circle sampler normalizes longitudes to [-180,180], so a transpacific leg jumped +-360 between neighbours and got split into two polylines pinned to opposite map edges. Unwrap the longitudes instead (shared flightGeodesy module for both renderers): Leaflet additionally draws a +-360-shifted copy so both halves show in the standard view, GL maps repeat world copies themselves. --- .../src/components/Map/ReservationOverlay.tsx | 38 +---------- .../src/components/Map/flightGeodesy.test.ts | 63 +++++++++++++++++++ client/src/components/Map/flightGeodesy.ts | 61 ++++++++++++++++++ .../src/components/Map/reservationsMapbox.ts | 43 ++----------- 4 files changed, 132 insertions(+), 73 deletions(-) create mode 100644 client/src/components/Map/flightGeodesy.test.ts create mode 100644 client/src/components/Map/flightGeodesy.ts diff --git a/client/src/components/Map/ReservationOverlay.tsx b/client/src/components/Map/ReservationOverlay.tsx index f7c7e0cbc..fec3a3007 100644 --- a/client/src/components/Map/ReservationOverlay.tsx +++ b/client/src/components/Map/ReservationOverlay.tsx @@ -5,6 +5,7 @@ import L from 'leaflet' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react' import { escapeHtml } from '@trek/shared' import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry' +import { geodesicArcs } from './flightGeodesy' import { useSettingsStore } from '../../store/settingsStore' import type { Reservation, ReservationEndpoint } from '../../types' @@ -64,41 +65,6 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon { } 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() @@ -375,7 +341,7 @@ export default function ReservationOverlay({ reservations, showConnections, show const a = waypoints[i] const b = waypoints[i + 1] const segArcs = isGeo - ? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng])) + ? 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]) diff --git a/client/src/components/Map/flightGeodesy.test.ts b/client/src/components/Map/flightGeodesy.test.ts new file mode 100644 index 000000000..8e2b743e5 --- /dev/null +++ b/client/src/components/Map/flightGeodesy.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy' + +const YVR: [number, number] = [49.19, -123.18] +const ICN: [number, number] = [37.46, 126.44] +const FRA: [number, number] = [50.03, 8.57] +const JFK: [number, number] = [40.64, -73.78] + +const maxConsecutiveDeltaLng = (pts: [number, number][]) => + pts.reduce((max, p, i) => (i === 0 ? 0 : Math.max(max, Math.abs(p[1] - pts[i - 1][1]))), 0) + +describe('flightGeodesy (#1411)', () => { + it('a date-line crossing unwraps into one continuous arc plus a shifted copy for Leaflet', () => { + const arcs = geodesicArcs(YVR, ICN, true) + expect(arcs).toHaveLength(2) + const [base, shifted] = arcs + // continuous: no ±360 jump anywhere + expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180) + // endpoints: starts at YVR, ends at ICN unwrapped westwards (126.44 - 360) + expect(base[0][0]).toBeCloseTo(YVR[0], 5) + expect(base[0][1]).toBeCloseTo(YVR[1], 5) + expect(base[base.length - 1][1]).toBeCloseTo(ICN[1] - 360, 5) + // the copy is the base shifted by exactly +360 + expect(shifted).toHaveLength(base.length) + shifted.forEach(([lat, lng], i) => { + expect(lat).toBe(base[i][0]) + expect(lng).toBeCloseTo(base[i][1] + 360, 10) + }) + }) + + it('an eastbound crossing unwraps upwards and shifts by -360', () => { + const arcs = geodesicArcs(ICN, YVR, true) + expect(arcs).toHaveLength(2) + const [base, shifted] = arcs + expect(maxConsecutiveDeltaLng(base)).toBeLessThan(180) + expect(base[base.length - 1][1]).toBeCloseTo(YVR[1] + 360, 5) + expect(shifted[0][1]).toBeCloseTo(base[0][1] - 360, 10) + }) + + it('a non-crossing flight stays a single in-range arc', () => { + const arcs = geodesicArcs(FRA, JFK, true) + expect(arcs).toHaveLength(1) + for (const [, lng] of arcs[0]) { + expect(lng).toBeGreaterThanOrEqual(-180) + expect(lng).toBeLessThanOrEqual(180) + } + }) + + it('GL mode always returns exactly one continuous arc (world copies handle the wrap)', () => { + const arcs = geodesicArcs(YVR, ICN, false) + expect(arcs).toHaveLength(1) + expect(maxConsecutiveDeltaLng(arcs[0])).toBeLessThan(180) + }) + + it('a zero-distance leg passes the endpoints through', () => { + expect(greatCircle(YVR, YVR)).toEqual([YVR, YVR]) + }) + + it('unwrapLngs keeps already-continuous input unchanged', () => { + const pts: [number, number][] = [[0, 170], [0, 175], [0, 179]] + expect(unwrapLngs(pts)).toEqual(pts) + }) +}) diff --git a/client/src/components/Map/flightGeodesy.ts b/client/src/components/Map/flightGeodesy.ts new file mode 100644 index 000000000..e423a67af --- /dev/null +++ b/client/src/components/Map/flightGeodesy.ts @@ -0,0 +1,61 @@ +// Great-circle geometry for transport routes (flights, cruises, ferries), +// shared by the Leaflet and Mapbox/MapLibre renderers (#1411). + +const toRad = (d: number) => d * Math.PI / 180 +const toDeg = (r: number) => r * 180 / Math.PI + +export 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 +} + +/** + * Make the longitudes of a sampled arc continuous: atan2 normalizes every + * sample to [-180, 180], so a date-line crossing shows up as a ±360 jump + * between neighbours — which the renderers draw as a line across the whole + * map. Carrying a running offset keeps each Δlng under 180°, at the cost of + * longitudes leaving the [-180, 180] range (both map libraries project those + * linearly, which is exactly what makes the wrap seamless). + */ +export function unwrapLngs(points: [number, number][]): [number, number][] { + let offset = 0 + return points.map(([lat, lng], i) => { + if (i === 0) return [lat, lng] as [number, number] + const prev = points[i - 1][1] + offset + let cur = lng + offset + if (cur - prev > 180) { offset -= 360; cur -= 360 } + else if (cur - prev < -180) { offset += 360; cur += 360 } + return [lat, cur] as [number, number] + }) +} + +/** + * The polylines to draw for one leg. The base arc is continuous (unwrapped), + * so panning across the antimeridian shows one unbroken line. With + * `wrapCopies` (Leaflet — its vector layers don't repeat across world + * copies), a ±360-shifted duplicate keeps both halves visible in the + * standard [-180, 180] view; GL maps repeat features themselves + * (renderWorldCopies), so the duplicate would just double the line opacity. + */ +export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] { + const arc = unwrapLngs(greatCircle(a, b)) + const crosses = arc.some(([, lng]) => lng < -180 || lng > 180) + if (!crosses || !wrapCopies) return [arc] + const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360 + return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])] +} diff --git a/client/src/components/Map/reservationsMapbox.ts b/client/src/components/Map/reservationsMapbox.ts index 34072ad9d..7b1c62ab7 100644 --- a/client/src/components/Map/reservationsMapbox.ts +++ b/client/src/components/Map/reservationsMapbox.ts @@ -11,6 +11,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import type mapboxgl from 'mapbox-gl' import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront } from 'lucide-react' import { getTransitMapSegments } from './transitGeometry' +import { geodesicArcs } from './flightGeodesy' import { escapeHtml } from '@trek/shared' import type { Reservation, ReservationEndpoint } from '../../types' @@ -34,43 +35,8 @@ const TYPE_META: Record 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 @@ -157,7 +123,10 @@ function buildItems(reservations: Reservation[]): TransportItem[] { const a = waypoints[i] const b = waypoints[i + 1] const segArcs = isGeo - ? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng])) + // 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])