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

This commit is contained in:
2026-09-22 14:48:53 +00:00
parent 23896b2cc4
commit cd2c766895
5 changed files with 168 additions and 6 deletions
+76
View File
@@ -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);
});
});