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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user