Unwrap great-circle arc longitudes across the antimeridian so routes don't chord across the map (ISS-120)
CI / build + unit + e2e (pull_request) Successful in 10m58s
CI / build + unit + e2e (pull_request) Successful in 10m58s
This commit is contained in:
@@ -8,8 +8,26 @@ const AIRPORTS = new Map<string, AirportCoord>([
|
||||
['EDDV', { lat: 52.46, lon: 9.68 }],
|
||||
['EHAM', { lat: 52.31, lon: 4.76 }],
|
||||
['LEMD', { lat: 40.47, lon: -3.56 }], // Madrid — ~1400 km from EDDF for the long-route test
|
||||
// ISS-120 (v0.53.0): real dataset pairs whose great circle crosses the antimeridian (±180).
|
||||
['NZAA', { lat: -37.01199, lon: 174.786331 }], // Auckland — east of +170
|
||||
['NFTF', { lat: -21.241438, lon: -175.149164 }], // Fua'amotu (Tonga) — west of −175; NZAA→NFTF crosses ±180 eastward
|
||||
['NZCH', { lat: -43.489029, lon: 172.532065 }], // Christchurch — for the queue-chain joint test
|
||||
]);
|
||||
|
||||
// ISS-120: longitude difference on the circle — two lons are "equal" when they differ by a multiple of 360.
|
||||
const modDiff = (a: number, b: number): number => {
|
||||
const d = Math.abs(a - b) % 360;
|
||||
return Math.min(d, 360 - d);
|
||||
};
|
||||
|
||||
// ISS-120: every consecutive pair of samples must be < 180° apart in longitude — the property that
|
||||
// keeps MapLibre from drawing a chord across the map.
|
||||
function expectContinuousLons(coords: [number, number][]): void {
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
expect(Math.abs(coords[i]![0] - coords[i - 1]![0]), `jump between samples ${i - 1} and ${i}`).toBeLessThan(180);
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
||||
return {
|
||||
account: { cash: 0, reputation: 0 },
|
||||
@@ -320,3 +338,61 @@ describe('greatCircleArc (v0.12.2 — curved route traces)', () => {
|
||||
expect(haversineKm(mid[1], mid[0], chordMid[1], chordMid[0])).toBeLessThan(5); // stays close to the chord
|
||||
});
|
||||
});
|
||||
|
||||
describe('greatCircleArc — antimeridian crossing (ISS-120 v0.53.0)', () => {
|
||||
const from = AIRPORTS.get('NZAA')!; // Auckland, lon +174.8
|
||||
const to = AIRPORTS.get('NFTF')!; // Fua'amotu, lon −175.1 — the short arc crosses ±180 EASTWARD
|
||||
|
||||
it('eastward crossing: continuous lons (|Δlon| < 180), endpoints exact mod 360, length ≈ haversine', () => {
|
||||
const arc = greatCircleArc(from, to);
|
||||
expect(arc.length).toBe(ROUTE_ARC_SEGMENTS + 1);
|
||||
expectContinuousLons(arc); // no wrapped ±180 jump anywhere — the chord bug is gone
|
||||
expect(arc[0]).toEqual([from.lon, from.lat]); // first sample = raw origin, exact
|
||||
const last = arc[arc.length - 1]!;
|
||||
expect(modDiff(last[0], to.lon)).toBeLessThan(1e-9); // last ≡ dest (mod 360) — here unwrapped to +184.8
|
||||
expect(Math.abs(last[1] - to.lat)).toBeLessThan(1e-9);
|
||||
expect(arc.some((c) => c[0] > 180)).toBe(true); // the crossing samples sit on the east world copy
|
||||
|
||||
const dist = haversineKm(from.lat, from.lon, to.lat, to.lon);
|
||||
let sum = 0;
|
||||
for (let i = 1; i < arc.length; i++) sum += haversineKm(arc[i - 1]![1], arc[i - 1]![0], arc[i]![1], arc[i]![0]);
|
||||
expect(Math.abs(sum - dist) / dist).toBeLessThan(0.01); // sampled chord length ≈ true great-circle distance
|
||||
});
|
||||
|
||||
it('westward crossing (reversed pair): continuous lons below −180, endpoints exact mod 360', () => {
|
||||
const arc = greatCircleArc(to, from);
|
||||
expectContinuousLons(arc);
|
||||
expect(arc[0]).toEqual([to.lon, to.lat]); // raw origin, exact
|
||||
const last = arc[arc.length - 1]!;
|
||||
expect(modDiff(last[0], from.lon)).toBeLessThan(1e-9); // ≡ +174.8 (mod 360) — here unwrapped to −185.2
|
||||
expect(Math.abs(last[1] - from.lat)).toBeLessThan(1e-9);
|
||||
expect(arc.some((c) => c[0] < -180)).toBe(true); // the crossing samples sit on the west world copy
|
||||
});
|
||||
|
||||
it('non-crossing routes are untouched: lons stay in [−180, 180]', () => {
|
||||
const arc = greatCircleArc(AIRPORTS.get('EDDF')!, AIRPORTS.get('LEMD')!);
|
||||
expectContinuousLons(arc);
|
||||
for (const c of arc) expect(c[0]).toBeGreaterThanOrEqual(-180);
|
||||
for (const c of arc) expect(c[0]).toBeLessThanOrEqual(180);
|
||||
});
|
||||
|
||||
it('queuedRoutesGeoJson: a chain crossing the antimeridian stays continuous across the joint', () => {
|
||||
// Grounded at NZAA with queue [NFTF, NZCH]: leg 1 crosses ±180 eastward (ends unwrapped at +184.8),
|
||||
// leg 2 restarts at the RAW −175.1 — without the global re-unwrap the joint would jump ~360°.
|
||||
const fc = queuedRoutesGeoJson(
|
||||
[
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'NZAA', locationName: 'A', destIcao: null, destName: null, departureSimTime: null, lat: -37.01, lon: 174.79, health: 100, repairUntilSimTime: null, queue: ['NFTF', 'NZCH'] },
|
||||
],
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(1);
|
||||
const coords = fc.features[0]!.geometry.coordinates;
|
||||
expect(coords.length).toBe(2 * ROUTE_ARC_SEGMENTS + 1); // two arcs, joint dropped once
|
||||
expectContinuousLons(coords); // the whole chain — including across the NFTF joint
|
||||
expect(coords[0]).toEqual([from.lon, from.lat]); // starts at the plane's airport, raw
|
||||
const last = coords[coords.length - 1]!;
|
||||
const nzch = AIRPORTS.get('NZCH')!;
|
||||
expect(modDiff(last[0], nzch.lon)).toBeLessThan(1e-9); // ends ≡ NZCH (mod 360)
|
||||
expect(Math.abs(last[1] - nzch.lat)).toBeLessThan(1e-9);
|
||||
});
|
||||
});
|
||||
|
||||
+24
-3
@@ -74,16 +74,34 @@ export interface AirportCoord {
|
||||
// curved path the plane actually flies (same slerp as lib/sim/movement.ts).
|
||||
export const ROUTE_ARC_SEGMENTS = 24;
|
||||
|
||||
// ISS-120 (v0.53.0): positionAt wraps each sample's longitude into [−180, 180], so an arc that
|
||||
// crosses the antimeridian jumps +179.x → −179.x between consecutive samples — and MapLibre draws
|
||||
// LineStrings as-is (no ±180 splitting), rendering a chord across the whole map. Unwrap the sample
|
||||
// sequence into continuous longitudes: keep the first sample's raw lon, then shift each subsequent
|
||||
// one by ±360 relative to the previous UNWRAPPED value until |Δlon| ≤ 180. One line stays one line;
|
||||
// out-of-range values render on MapLibre's adjacent world copies (renderWorldCopies is default-on).
|
||||
function unwrapLons(coords: [number, number][]): [number, number][] {
|
||||
let prev = coords[0]![0];
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
let lon = coords[i]![0];
|
||||
while (lon - prev > 180) lon -= 360;
|
||||
while (prev - lon > 180) lon += 360;
|
||||
coords[i]![0] = lon;
|
||||
prev = lon;
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
// Great-circle arc between two airports as [lon, lat] coordinates: positionAt at
|
||||
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — endpoints are exactly the airport
|
||||
// coords (positionAt returns them verbatim at t=0 and t=1).
|
||||
// t = i/SEGMENTS for i in 0..SEGMENTS. Pure — the first sample is exactly `from` and the last is
|
||||
// ≡ `to` (mod 360) (positionAt returns them verbatim at t=0 and t=1, unwrapped relative to from).
|
||||
export function greatCircleArc(from: AirportCoord, to: AirportCoord): [number, number][] {
|
||||
const coords: [number, number][] = [];
|
||||
for (let i = 0; i <= ROUTE_ARC_SEGMENTS; i++) {
|
||||
const pos = positionAt(from.lat, from.lon, to.lat, to.lon, i / ROUTE_ARC_SEGMENTS);
|
||||
coords.push([pos.lon, pos.lat]);
|
||||
}
|
||||
return coords;
|
||||
return unwrapLons(coords);
|
||||
}
|
||||
|
||||
// ISS-022/025/027 (v0.22.0): the hover route trace — one great-circle LineString between two
|
||||
@@ -195,6 +213,9 @@ export function queuedRoutesGeoJson(
|
||||
const arc = greatCircleArc(pts[i]!, pts[i + 1]!);
|
||||
coords.push(...(i === 0 ? arc : arc.slice(1))); // drop the duplicated joint point
|
||||
}
|
||||
// ISS-120: each leg's arc is unwrapped relative to its OWN start airport, so at a joint the
|
||||
// sequence can jump ±360 (leg 1 ends at dest+360k, leg 2 restarts at the raw dest) — re-unify.
|
||||
unwrapLons(coords);
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'LineString', coordinates: coords },
|
||||
|
||||
Reference in New Issue
Block a user