mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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.
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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])]
|
||||
}
|
||||
@@ -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<TransportType, { icon: typeof Plane; geodesic: boolean }
|
||||
transport_other: { icon: Route, geodesic: false },
|
||||
}
|
||||
|
||||
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
|
||||
// ── geometry helpers (shared with ReservationOverlay via flightGeodesy) ──
|
||||
const toRad = (d: number) => 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])
|
||||
|
||||
Reference in New Issue
Block a user