From 5f58c4ff5ce306a7617c148f23ce8d19a6ce6fab Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sun, 29 Mar 2026 18:34:46 -0700 Subject: [PATCH 1/5] test: add antimeridian splitting tests for low npoints values --- test/antimeridian.test.ts | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 test/antimeridian.test.ts diff --git a/test/antimeridian.test.ts b/test/antimeridian.test.ts new file mode 100644 index 0000000..917c417 --- /dev/null +++ b/test/antimeridian.test.ts @@ -0,0 +1,71 @@ +import { GreatCircle } from '../src'; +import type { MultiLineString, LineString } from 'geojson'; + +// Routes that cross the antimeridian +const PACIFIC_ROUTES = [ + { name: 'Tokyo → LAX', start: { x: 139.7798, y: 35.5494 }, end: { x: -118.4085, y: 33.9416 } }, + { name: 'Auckland → LAX', start: { x: 174.79, y: -36.85 }, end: { x: -118.41, y: 33.94 } }, + { name: 'Shanghai → SFO', start: { x: 121.81, y: 31.14 }, end: { x: -122.38, y: 37.62 } }, +]; + +function assertSplitAtAntimeridian(coords: number[][][]) { + const seg0 = coords[0]; + const seg1 = coords[1]; + + expect(seg0).toBeDefined(); + expect(seg1).toBeDefined(); + + if (!seg0 || !seg1) return; // narrow for TS + + const lastOfFirst = seg0[seg0.length - 1]; + const firstOfSecond = seg1[0]; + + expect(lastOfFirst).toBeDefined(); + expect(firstOfSecond).toBeDefined(); + + if (!lastOfFirst || !firstOfSecond) return; // narrow for TS + + // Both sides of the split must be at ±180 + expect(Math.abs(lastOfFirst[0] ?? NaN)).toBeCloseTo(180, 1); + expect(Math.abs(firstOfSecond[0] ?? NaN)).toBeCloseTo(180, 1); + + // Latitudes must match — no gap at the antimeridian + expect(lastOfFirst[1] ?? NaN).toBeCloseTo(firstOfSecond[1] ?? NaN, 3); +} + +describe('antimeridian splitting', () => { + describe('with npoints=100', () => { + for (const { name, start, end } of PACIFIC_ROUTES) { + test(`${name} produces a split MultiLineString`, () => { + const result = new GreatCircle(start, end).Arc(100, { offset: 10 }).json(); + expect(result.geometry.type).toBe('MultiLineString'); + assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates); + }); + } + }); + + describe('with npoints=10', () => { + for (const { name, start, end } of PACIFIC_ROUTES) { + test(`${name} splits correctly`, () => { + const result = new GreatCircle(start, end).Arc(10, { offset: 10 }).json(); + expect(result.geometry.type).toBe('MultiLineString'); + assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates); + }); + } + }); + + describe('non-crossing routes are unaffected', () => { + test('Seattle → DC returns a LineString with no longitude jumps', () => { + const result = new GreatCircle({ x: -122, y: 48 }, { x: -77, y: 39 }).Arc(100, { offset: 10 }).json(); + expect(result.geometry.type).toBe('LineString'); + + const coords = (result.geometry as LineString).coordinates; + for (let i = 1; i < coords.length; i++) { + const prev = coords[i - 1]; + const curr = coords[i]; + if (!prev || !curr) continue; + expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(20); + } + }); + }); +}); From cc9037ecefb062dc3fa6cf90f536847b0c770395 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 9 Apr 2026 11:16:22 -0700 Subject: [PATCH 2/5] fix(great-circle): replace GDAL heuristic with analytical antimeridian splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove bHasBigDiff / dfMaxSmallDiffLong / dfDateLineOffset heuristic - Bisect for exact crossing fraction via interpolate() (50 iterations) - Insert [±180, lat*] boundary points; npoints ≤ 2 keeps current behavior - Fixes issue #75: low npoints (e.g. 10) no longer skips the split - Tighten test assertions: SPLIT_NPOINTS constant, directional ±180 checks --- src/great-circle.ts | 175 ++++++++++++++------------------------ test/antimeridian.test.ts | 144 ++++++++++++++++++++----------- 2 files changed, 156 insertions(+), 163 deletions(-) diff --git a/src/great-circle.ts b/src/great-circle.ts index a4ac11f..287c601 100644 --- a/src/great-circle.ts +++ b/src/great-circle.ts @@ -98,133 +98,84 @@ export class GreatCircle { * console.log(greatCircle.Arc(10)); // Arc { geometries: [ [Array] ] } * ``` */ - Arc(npoints?: number, options?: ArcOptions): Arc { - let first_pass: [number, number][] = []; - + Arc(npoints?: number, _options?: ArcOptions): Arc { + // NOTE: With npoints ≤ 2, no antimeridian splitting is performed. + // A 2-point antimeridian route returns a single LineString spanning ±180°. + // Renderers that support coordinate wrapping (e.g. MapLibre GL JS) handle this + // correctly, whereas splitting would produce two disconnected straight-line stubs + // with no great-circle curvature — arguably worse behavior. This is a known + // limitation; open for maintainer discussion if a MultiLineString split is preferred. if (!npoints || npoints <= 2) { - first_pass.push([this.start.lon, this.start.lat]); - first_pass.push([this.end.lon, this.end.lat]); - } else { - const delta = 1.0 / (npoints - 1); - for (let i = 0; i < npoints; ++i) { - const step = delta * i; - const pair = this.interpolate(step); - first_pass.push(pair); - } + const arc = new Arc(this.properties); + const line = new _LineString(); + arc.geometries.push(line); + line.move_to(roundCoords([this.start.lon, this.start.lat])); + line.move_to(roundCoords([this.end.lon, this.end.lat])); + return arc; } - /* partial port of dateline handling from: - gdal/ogr/ogrgeometryfactory.cpp - - TODO - does not handle all wrapping scenarios yet - */ - let bHasBigDiff = false; - let dfMaxSmallDiffLong = 0; - // from http://www.gdal.org/ogr2ogr.html - // -datelineoffset: - // (starting with GDAL 1.10) offset from dateline in degrees (default long. = +/- 10deg, geometries within 170deg to -170deg will be splited) - const dfDateLineOffset = options?.offset ?? 10; - const dfLeftBorderX = 180 - dfDateLineOffset; - const dfRightBorderX = -180 + dfDateLineOffset; - const dfDiffSpace = 360 - dfDateLineOffset; - - // https://github.com/OSGeo/gdal/blob/7bfb9c452a59aac958bff0c8386b891edf8154ca/gdal/ogr/ogrgeometryfactory.cpp#L2342 - for (let j = 1; j < first_pass.length; ++j) { - const dfPrevX = first_pass[j-1]?.[0] ?? 0; - const dfX = first_pass[j]?.[0] ?? 0; - const dfDiffLong = Math.abs(dfX - dfPrevX); - if (dfDiffLong > dfDiffSpace && - ((dfX > dfLeftBorderX && dfPrevX < dfRightBorderX) || (dfPrevX > dfLeftBorderX && dfX < dfRightBorderX))) { - bHasBigDiff = true; - } else if (dfDiffLong > dfMaxSmallDiffLong) { - dfMaxSmallDiffLong = dfDiffLong; - } + // NOTE: options.offset was previously used as dfDateLineOffset in the GDAL-ported + // heuristic. It is kept in ArcOptions for backwards compatibility but is a no-op here. + + const delta = 1.0 / (npoints - 1); + const first_pass: [number, number][] = []; + for (let i = 0; i < npoints; ++i) { + first_pass.push(this.interpolate(delta * i)); } - const poMulti: [number, number][][] = []; - if (bHasBigDiff && dfMaxSmallDiffLong < dfDateLineOffset) { - let poNewLS: [number, number][] = []; - poMulti.push(poNewLS); - for (let k = 0; k < first_pass.length; ++k) { - const dfX0 = parseFloat((first_pass[k]?.[0] ?? 0).toString()); - if (k > 0 && Math.abs(dfX0 - (first_pass[k-1]?.[0] ?? 0)) > dfDiffSpace) { - let dfX1 = parseFloat((first_pass[k-1]?.[0] ?? 0).toString()); - let dfY1 = parseFloat((first_pass[k-1]?.[1] ?? 0).toString()); - let dfX2 = parseFloat((first_pass[k]?.[0] ?? 0).toString()); - let dfY2 = parseFloat((first_pass[k]?.[1] ?? 0).toString()); - if (dfX1 > -180 && dfX1 < dfRightBorderX && dfX2 === 180 && - k+1 < first_pass.length && - (first_pass[k-1]?.[0] ?? 0) > -180 && (first_pass[k-1]?.[0] ?? 0) < dfRightBorderX) - { - poNewLS.push([-180, first_pass[k]?.[1] ?? 0]); - k++; - poNewLS.push([first_pass[k]?.[0] ?? 0, first_pass[k]?.[1] ?? 0]); - continue; - } else if (dfX1 > dfLeftBorderX && dfX1 < 180 && dfX2 === -180 && - k+1 < first_pass.length && - (first_pass[k-1]?.[0] ?? 0) > dfLeftBorderX && (first_pass[k-1]?.[0] ?? 0) < 180) - { - poNewLS.push([180, first_pass[k]?.[1] ?? 0]); - k++; - poNewLS.push([first_pass[k]?.[0] ?? 0, first_pass[k]?.[1] ?? 0]); - continue; - } + // Analytical antimeridian splitting via bisection. + // For each consecutive pair of points where |Δlon| > 180 (opposite sides of ±180°), + // binary-search for the exact crossing fraction f* using interpolate(), then insert + // [±180, lat*] boundary points and start a new segment. 50 iterations → sub-nanodegree precision. + const segments: [number, number][][] = []; + let current: [number, number][] = []; - if (dfX1 < dfRightBorderX && dfX2 > dfLeftBorderX) - { - // swap dfX1, dfX2 - const tmpX = dfX1; - dfX1 = dfX2; - dfX2 = tmpX; - // swap dfY1, dfY2 - const tmpY = dfY1; - dfY1 = dfY2; - dfY2 = tmpY; - } - if (dfX1 > dfLeftBorderX && dfX2 < dfRightBorderX) { - dfX2 += 360; - } + for (let i = 0; i < first_pass.length; i++) { + const pt = first_pass[i]!; - if (dfX1 <= 180 && dfX2 >= 180 && dfX1 < dfX2) - { - const dfRatio = (180 - dfX1) / (dfX2 - dfX1); - const dfY = dfRatio * dfY2 + (1 - dfRatio) * dfY1; - poNewLS.push([(first_pass[k-1]?.[0] ?? 0) > dfLeftBorderX ? 180 : -180, dfY]); - poNewLS = []; - poNewLS.push([(first_pass[k-1]?.[0] ?? 0) > dfLeftBorderX ? -180 : 180, dfY]); - poMulti.push(poNewLS); - } - else - { - poNewLS = []; - poMulti.push(poNewLS); + if (i === 0) { + current.push(pt); + continue; + } + + const prev = first_pass[i - 1]!; + + if (Math.abs(pt[0] - prev[0]) > 180) { + let lo = delta * (i - 1); + let hi = delta * i; + + for (let iter = 0; iter < 50; iter++) { + const mid = (lo + hi) / 2; + const [midLon] = this.interpolate(mid); + const [loLon] = this.interpolate(lo); + if (Math.abs(midLon - loLon) < 180) { + lo = mid; + } else { + hi = mid; } - poNewLS.push([dfX0, first_pass[k]?.[1] ?? 0]); - } else { - poNewLS.push([first_pass[k]?.[0] ?? 0, first_pass[k]?.[1] ?? 0]); } + + const [, crossingLat] = this.interpolate((lo + hi) / 2); + const fromEast = prev[0] > 0; + + current.push([fromEast ? 180 : -180, crossingLat]); + segments.push(current); + current = [[fromEast ? -180 : 180, crossingLat]]; } - } else { - // add normally - const poNewLS0: [number, number][] = []; - poMulti.push(poNewLS0); - for (let l = 0; l < first_pass.length; ++l) { - poNewLS0.push([first_pass[l]?.[0] ?? 0, first_pass[l]?.[1] ?? 0]); - } + + current.push(pt); + } + + if (current.length > 0) { + segments.push(current); } const arc = new Arc(this.properties); - for (let m = 0; m < poMulti.length; ++m) { + for (const seg of segments) { const line = new _LineString(); arc.geometries.push(line); - const points = poMulti[m]; - if (points) { - for (let j0 = 0; j0 < points.length; ++j0) { - const point = points[j0]; - if (point) { - line.move_to(roundCoords([point[0], point[1]])); - } - } + for (const pt of seg) { + line.move_to(roundCoords([pt[0], pt[1]])); } } return arc; diff --git a/test/antimeridian.test.ts b/test/antimeridian.test.ts index 917c417..fed7c4b 100644 --- a/test/antimeridian.test.ts +++ b/test/antimeridian.test.ts @@ -1,71 +1,113 @@ import { GreatCircle } from '../src'; import type { MultiLineString, LineString } from 'geojson'; -// Routes that cross the antimeridian -const PACIFIC_ROUTES = [ - { name: 'Tokyo → LAX', start: { x: 139.7798, y: 35.5494 }, end: { x: -118.4085, y: 33.9416 } }, - { name: 'Auckland → LAX', start: { x: 174.79, y: -36.85 }, end: { x: -118.41, y: 33.94 } }, - { name: 'Shanghai → SFO', start: { x: 121.81, y: 31.14 }, end: { x: -122.38, y: 37.62 } }, +// npoints values exercised for antimeridian-crossing routes. +// 10 → large step size (~50°), the low-npoints regression from issue #75 +// 100 → fine-grained, original failure mode from PR #55 / turf#3030 +const SPLIT_NPOINTS = [10, 100] as const; + +// East-to-west Pacific crossings (positive → negative longitude) +const EAST_TO_WEST = [ + { name: 'Tokyo → LAX', start: { x: 139.7798, y: 35.5494 }, end: { x: -118.4085, y: 33.9416 } }, + { name: 'Auckland → LAX', start: { x: 174.79, y: -36.85 }, end: { x: -118.41, y: 33.94 } }, + { name: 'Shanghai → SFO', start: { x: 121.81, y: 31.14 }, end: { x: -122.38, y: 37.62 } }, ]; -function assertSplitAtAntimeridian(coords: number[][][]) { - const seg0 = coords[0]; - const seg1 = coords[1]; +// West-to-east Pacific crossings (negative → positive longitude) +const WEST_TO_EAST = [ + { name: 'LAX → Tokyo', start: { x: -118.4085, y: 33.9416 }, end: { x: 139.7798, y: 35.5494 } }, + { name: 'LAX → Auckland', start: { x: -118.41, y: 33.94 }, end: { x: 174.79, y: -36.85 } }, + { name: 'SFO → Shanghai', start: { x: -122.38, y: 37.62 }, end: { x: 121.81, y: 31.14 } }, +]; - expect(seg0).toBeDefined(); - expect(seg1).toBeDefined(); +// High-latitude routes that approach the poles +const HIGH_LATITUDE = [ + { name: 'Oslo → Anchorage', start: { x: 10.74, y: 59.91 }, end: { x: -149.9, y: 61.22 } }, + { name: 'London → Seattle', start: { x: -0.12, y: 51.51 }, end: { x: -122.33, y: 47.61 } }, +]; - if (!seg0 || !seg1) return; // narrow for TS +function assertSplitAtAntimeridian(coords: number[][][], fromEast: boolean) { + const seg0 = coords[0]; + const seg1 = coords[1]; - const lastOfFirst = seg0[seg0.length - 1]; - const firstOfSecond = seg1[0]; + expect(seg0).toBeDefined(); + expect(seg1).toBeDefined(); + if (!seg0 || !seg1) return; - expect(lastOfFirst).toBeDefined(); - expect(firstOfSecond).toBeDefined(); + const lastOfFirst = seg0[seg0.length - 1]; + const firstOfSecond = seg1[0]; - if (!lastOfFirst || !firstOfSecond) return; // narrow for TS + expect(lastOfFirst).toBeDefined(); + expect(firstOfSecond).toBeDefined(); + if (!lastOfFirst || !firstOfSecond) return; - // Both sides of the split must be at ±180 - expect(Math.abs(lastOfFirst[0] ?? NaN)).toBeCloseTo(180, 1); - expect(Math.abs(firstOfSecond[0] ?? NaN)).toBeCloseTo(180, 1); + // Segment 1 must end at the correct side of the antimeridian + expect(lastOfFirst[0] ?? NaN).toBeCloseTo(fromEast ? 180 : -180, 1); + expect(firstOfSecond[0] ?? NaN).toBeCloseTo(fromEast ? -180 : 180, 1); - // Latitudes must match — no gap at the antimeridian - expect(lastOfFirst[1] ?? NaN).toBeCloseTo(firstOfSecond[1] ?? NaN, 3); + // Latitudes must match — no gap + expect(lastOfFirst[1] ?? NaN).toBeCloseTo(firstOfSecond[1] ?? NaN, 3); } -describe('antimeridian splitting', () => { - describe('with npoints=100', () => { - for (const { name, start, end } of PACIFIC_ROUTES) { - test(`${name} produces a split MultiLineString`, () => { - const result = new GreatCircle(start, end).Arc(100, { offset: 10 }).json(); - expect(result.geometry.type).toBe('MultiLineString'); - assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates); - }); +describe('antimeridian splitting — east to west', () => { + for (const npoints of SPLIT_NPOINTS) { + describe(`npoints=${npoints}`, () => { + for (const { name, start, end } of EAST_TO_WEST) { + test(`${name} splits at antimeridian`, () => { + const result = new GreatCircle(start, end).Arc(npoints).json(); + expect(result.geometry.type).toBe('MultiLineString'); + assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates, true); + }); + } + }); + } +}); + +describe('antimeridian splitting — west to east', () => { + for (const npoints of SPLIT_NPOINTS) { + describe(`npoints=${npoints}`, () => { + for (const { name, start, end } of WEST_TO_EAST) { + test(`${name} splits at antimeridian`, () => { + const result = new GreatCircle(start, end).Arc(npoints).json(); + expect(result.geometry.type).toBe('MultiLineString'); + assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates, false); + }); + } + }); } - }); - - describe('with npoints=10', () => { - for (const { name, start, end } of PACIFIC_ROUTES) { - test(`${name} splits correctly`, () => { - const result = new GreatCircle(start, end).Arc(10, { offset: 10 }).json(); - expect(result.geometry.type).toBe('MultiLineString'); - assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates); - }); +}); + +describe('high-latitude routes', () => { + for (const { name, start, end } of HIGH_LATITUDE) { + test(`${name} produces valid GeoJSON with no large longitude jumps`, () => { + const result = new GreatCircle(start, end).Arc(100).json(); + expect(['LineString', 'MultiLineString']).toContain(result.geometry.type); + + const allCoords: number[][] = result.geometry.type === 'MultiLineString' + ? (result.geometry as MultiLineString).coordinates.flat() + : (result.geometry as LineString).coordinates; + + for (let i = 1; i < allCoords.length; i++) { + const prev = allCoords[i - 1]; + const curr = allCoords[i]; + if (!prev || !curr) continue; + expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(180); + } + }); } - }); +}); - describe('non-crossing routes are unaffected', () => { +describe('non-crossing routes are unaffected', () => { test('Seattle → DC returns a LineString with no longitude jumps', () => { - const result = new GreatCircle({ x: -122, y: 48 }, { x: -77, y: 39 }).Arc(100, { offset: 10 }).json(); - expect(result.geometry.type).toBe('LineString'); - - const coords = (result.geometry as LineString).coordinates; - for (let i = 1; i < coords.length; i++) { - const prev = coords[i - 1]; - const curr = coords[i]; - if (!prev || !curr) continue; - expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(20); - } + const result = new GreatCircle({ x: -122, y: 48 }, { x: -77, y: 39 }).Arc(100).json(); + expect(result.geometry.type).toBe('LineString'); + + const coords = (result.geometry as LineString).coordinates; + for (let i = 1; i < coords.length; i++) { + const prev = coords[i - 1]; + const curr = coords[i]; + if (!prev || !curr) continue; + expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(20); + } }); - }); }); From d3598f25d8a1d4d56a3c367894643840ca498dee Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 9 Apr 2026 11:25:25 -0700 Subject: [PATCH 3/5] test(antimeridian): assert exactly 2 segments at antimeridian split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review comment — adds coords.length === 2 check to assertSplitAtAntimeridian to guard against false positives from 3+ segment splits. --- test/antimeridian.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/antimeridian.test.ts b/test/antimeridian.test.ts index fed7c4b..62eb38f 100644 --- a/test/antimeridian.test.ts +++ b/test/antimeridian.test.ts @@ -27,6 +27,9 @@ const HIGH_LATITUDE = [ ]; function assertSplitAtAntimeridian(coords: number[][][], fromEast: boolean) { + // Exactly 2 segments — guards against false positives from 3+ segment splits + expect(coords.length).toBe(2); + const seg0 = coords[0]; const seg1 = coords[1]; From cbf2c4ead2b22c7ca4314cbc3d1fa58f73bd17dd Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 9 Apr 2026 14:27:13 -0700 Subject: [PATCH 4/5] test(integration): replace brittle snapshots with semantic assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Routes 2-5 (antimeridian crossers): replace stale coordinate snapshots with semantic assertions (Feature type, properties pass-through, WKT contains two LINESTRING parts). Splitting correctness is owned by antimeridian.test.ts. - Southern hemisphere filter: switch to coordinate comparison (start.y < 0 || end.y < 0) and flatten MultiLineString coordinates before .some() to fix number[][][] vs number[][] traversal bug. - Add south-to-south antimeridian crossing coverage: Sydney ↔ Buenos Aires at npoints=10 and 100 in both directions. - Reformat antimeridian.test.ts to consistent 2-space indentation. - Add geographic place names to all routes for maintainer clarity. --- test/antimeridian.test.ts | 185 +++++++++++++++++++++++--------------- test/integration.test.ts | 156 +++++++++++--------------------- 2 files changed, 164 insertions(+), 177 deletions(-) diff --git a/test/antimeridian.test.ts b/test/antimeridian.test.ts index 62eb38f..61f5b9e 100644 --- a/test/antimeridian.test.ts +++ b/test/antimeridian.test.ts @@ -8,109 +8,146 @@ const SPLIT_NPOINTS = [10, 100] as const; // East-to-west Pacific crossings (positive → negative longitude) const EAST_TO_WEST = [ - { name: 'Tokyo → LAX', start: { x: 139.7798, y: 35.5494 }, end: { x: -118.4085, y: 33.9416 } }, - { name: 'Auckland → LAX', start: { x: 174.79, y: -36.85 }, end: { x: -118.41, y: 33.94 } }, - { name: 'Shanghai → SFO', start: { x: 121.81, y: 31.14 }, end: { x: -122.38, y: 37.62 } }, + { name: 'Tokyo → LAX', start: { x: 139.7798, y: 35.5494 }, end: { x: -118.4085, y: 33.9416 } }, + { name: 'Auckland → LAX', start: { x: 174.79, y: -36.85 }, end: { x: -118.41, y: 33.94 } }, + { name: 'Shanghai → SFO', start: { x: 121.81, y: 31.14 }, end: { x: -122.38, y: 37.62 } }, ]; // West-to-east Pacific crossings (negative → positive longitude) const WEST_TO_EAST = [ - { name: 'LAX → Tokyo', start: { x: -118.4085, y: 33.9416 }, end: { x: 139.7798, y: 35.5494 } }, - { name: 'LAX → Auckland', start: { x: -118.41, y: 33.94 }, end: { x: 174.79, y: -36.85 } }, - { name: 'SFO → Shanghai', start: { x: -122.38, y: 37.62 }, end: { x: 121.81, y: 31.14 } }, + { name: 'LAX → Tokyo', start: { x: -118.4085, y: 33.9416 }, end: { x: 139.7798, y: 35.5494 } }, + { name: 'LAX → Auckland', start: { x: -118.41, y: 33.94 }, end: { x: 174.79, y: -36.85 } }, + { name: 'SFO → Shanghai', start: { x: -122.38, y: 37.62 }, end: { x: 121.81, y: 31.14 } }, +]; + +// South-to-south Pacific crossings (both endpoints in southern hemisphere) +const SOUTH_TO_SOUTH_E_TO_W = [ + { name: 'Sydney → Buenos Aires', start: { x: 151.21, y: -33.87 }, end: { x: -58.38, y: -34.60 } }, +]; + +const SOUTH_TO_SOUTH_W_TO_E = [ + { name: 'Buenos Aires → Sydney', start: { x: -58.38, y: -34.60 }, end: { x: 151.21, y: -33.87 } }, ]; // High-latitude routes that approach the poles const HIGH_LATITUDE = [ - { name: 'Oslo → Anchorage', start: { x: 10.74, y: 59.91 }, end: { x: -149.9, y: 61.22 } }, - { name: 'London → Seattle', start: { x: -0.12, y: 51.51 }, end: { x: -122.33, y: 47.61 } }, + { name: 'Oslo → Anchorage', start: { x: 10.74, y: 59.91 }, end: { x: -149.9, y: 61.22 } }, + { name: 'London → Seattle', start: { x: -0.12, y: 51.51 }, end: { x: -122.33, y: 47.61 } }, ]; function assertSplitAtAntimeridian(coords: number[][][], fromEast: boolean) { - // Exactly 2 segments — guards against false positives from 3+ segment splits - expect(coords.length).toBe(2); + // Exactly 2 segments — guards against false positives from 3+ segment splits + expect(coords.length).toBe(2); - const seg0 = coords[0]; - const seg1 = coords[1]; + const seg0 = coords[0]; + const seg1 = coords[1]; - expect(seg0).toBeDefined(); - expect(seg1).toBeDefined(); - if (!seg0 || !seg1) return; + expect(seg0).toBeDefined(); + expect(seg1).toBeDefined(); + if (!seg0 || !seg1) return; - const lastOfFirst = seg0[seg0.length - 1]; - const firstOfSecond = seg1[0]; + const lastOfFirst = seg0[seg0.length - 1]; + const firstOfSecond = seg1[0]; - expect(lastOfFirst).toBeDefined(); - expect(firstOfSecond).toBeDefined(); - if (!lastOfFirst || !firstOfSecond) return; + expect(lastOfFirst).toBeDefined(); + expect(firstOfSecond).toBeDefined(); + if (!lastOfFirst || !firstOfSecond) return; - // Segment 1 must end at the correct side of the antimeridian - expect(lastOfFirst[0] ?? NaN).toBeCloseTo(fromEast ? 180 : -180, 1); - expect(firstOfSecond[0] ?? NaN).toBeCloseTo(fromEast ? -180 : 180, 1); + // Segment 1 must end at the correct side of the antimeridian + expect(lastOfFirst[0] ?? NaN).toBeCloseTo(fromEast ? 180 : -180, 1); + expect(firstOfSecond[0] ?? NaN).toBeCloseTo(fromEast ? -180 : 180, 1); - // Latitudes must match — no gap - expect(lastOfFirst[1] ?? NaN).toBeCloseTo(firstOfSecond[1] ?? NaN, 3); + // Latitudes must match — no gap + expect(lastOfFirst[1] ?? NaN).toBeCloseTo(firstOfSecond[1] ?? NaN, 3); } describe('antimeridian splitting — east to west', () => { - for (const npoints of SPLIT_NPOINTS) { - describe(`npoints=${npoints}`, () => { - for (const { name, start, end } of EAST_TO_WEST) { - test(`${name} splits at antimeridian`, () => { - const result = new GreatCircle(start, end).Arc(npoints).json(); - expect(result.geometry.type).toBe('MultiLineString'); - assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates, true); - }); - } + for (const npoints of SPLIT_NPOINTS) { + describe(`npoints=${npoints}`, () => { + for (const { name, start, end } of EAST_TO_WEST) { + test(`${name} splits at antimeridian`, () => { + const result = new GreatCircle(start, end).Arc(npoints).json(); + expect(result.geometry.type).toBe('MultiLineString'); + assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates, true); }); - } + } + }); + } }); describe('antimeridian splitting — west to east', () => { - for (const npoints of SPLIT_NPOINTS) { - describe(`npoints=${npoints}`, () => { - for (const { name, start, end } of WEST_TO_EAST) { - test(`${name} splits at antimeridian`, () => { - const result = new GreatCircle(start, end).Arc(npoints).json(); - expect(result.geometry.type).toBe('MultiLineString'); - assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates, false); - }); - } + for (const npoints of SPLIT_NPOINTS) { + describe(`npoints=${npoints}`, () => { + for (const { name, start, end } of WEST_TO_EAST) { + test(`${name} splits at antimeridian`, () => { + const result = new GreatCircle(start, end).Arc(npoints).json(); + expect(result.geometry.type).toBe('MultiLineString'); + assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates, false); }); - } + } + }); + } }); -describe('high-latitude routes', () => { - for (const { name, start, end } of HIGH_LATITUDE) { - test(`${name} produces valid GeoJSON with no large longitude jumps`, () => { - const result = new GreatCircle(start, end).Arc(100).json(); - expect(['LineString', 'MultiLineString']).toContain(result.geometry.type); - - const allCoords: number[][] = result.geometry.type === 'MultiLineString' - ? (result.geometry as MultiLineString).coordinates.flat() - : (result.geometry as LineString).coordinates; - - for (let i = 1; i < allCoords.length; i++) { - const prev = allCoords[i - 1]; - const curr = allCoords[i]; - if (!prev || !curr) continue; - expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(180); - } +describe('antimeridian splitting — south to south, east to west', () => { + for (const npoints of SPLIT_NPOINTS) { + describe(`npoints=${npoints}`, () => { + for (const { name, start, end } of SOUTH_TO_SOUTH_E_TO_W) { + test(`${name} splits at antimeridian`, () => { + const result = new GreatCircle(start, end).Arc(npoints).json(); + expect(result.geometry.type).toBe('MultiLineString'); + assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates, true); }); - } + } + }); + } }); -describe('non-crossing routes are unaffected', () => { - test('Seattle → DC returns a LineString with no longitude jumps', () => { - const result = new GreatCircle({ x: -122, y: 48 }, { x: -77, y: 39 }).Arc(100).json(); - expect(result.geometry.type).toBe('LineString'); - - const coords = (result.geometry as LineString).coordinates; - for (let i = 1; i < coords.length; i++) { - const prev = coords[i - 1]; - const curr = coords[i]; - if (!prev || !curr) continue; - expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(20); - } +describe('antimeridian splitting — south to south, west to east', () => { + for (const npoints of SPLIT_NPOINTS) { + describe(`npoints=${npoints}`, () => { + for (const { name, start, end } of SOUTH_TO_SOUTH_W_TO_E) { + test(`${name} splits at antimeridian`, () => { + const result = new GreatCircle(start, end).Arc(npoints).json(); + expect(result.geometry.type).toBe('MultiLineString'); + assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates, false); + }); + } + }); + } +}); + +describe('high-latitude routes', () => { + for (const { name, start, end } of HIGH_LATITUDE) { + test(`${name} produces valid GeoJSON with no large longitude jumps`, () => { + const result = new GreatCircle(start, end).Arc(100).json(); + expect(['LineString', 'MultiLineString']).toContain(result.geometry.type); + + const allCoords: number[][] = result.geometry.type === 'MultiLineString' + ? (result.geometry as MultiLineString).coordinates.flat() + : (result.geometry as LineString).coordinates; + + for (let i = 1; i < allCoords.length; i++) { + const prev = allCoords[i - 1]; + const curr = allCoords[i]; + if (!prev || !curr) continue; + expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(180); + } }); + } +}); + +describe('non-crossing routes are unaffected', () => { + test('Seattle → DC returns a LineString with no longitude jumps', () => { + const result = new GreatCircle({ x: -122, y: 48 }, { x: -77, y: 39 }).Arc(100).json(); + expect(result.geometry.type).toBe('LineString'); + + const coords = (result.geometry as LineString).coordinates; + for (let i = 1; i < coords.length; i++) { + const prev = coords[i - 1]; + const curr = coords[i]; + if (!prev || !curr) continue; + expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(20); + } + }); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index dda3aad..0d143e0 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,48 +1,59 @@ import { GreatCircle, CoordinatePoint } from '../src'; +import type { MultiLineString, LineString } from 'geojson'; // Complex real-world routes for integration testing interface TestRoute { start: CoordinatePoint; end: CoordinatePoint; properties: { name: string }; + crossesAntimeridian: boolean; } const routes: TestRoute[] = [ { start: { x: -122, y: 48 }, end: { x: -77, y: 39 }, - properties: { name: 'Seattle to DC' } + properties: { name: 'Seattle → DC' }, + crossesAntimeridian: false }, { start: { x: -122, y: 48 }, end: { x: 0, y: 51 }, - properties: { name: 'Seattle to London' } + properties: { name: 'Seattle → London' }, + crossesAntimeridian: false }, { start: { x: -75.9375, y: 35.460669951495305 }, end: { x: 146.25, y: -43.06888777416961 }, - properties: { name: 'crosses dateline 1' } + properties: { name: 'Pamlico Sound, NC, USA → Tasmania, Australia' }, + crossesAntimeridian: true }, { start: { x: 145.54687500000003, y: 48.45835188280866 }, end: { x: -112.5, y: -37.71859032558814 }, - properties: { name: 'crosses dateline 2' } + properties: { name: 'Sea of Okhotsk, Russia → Southern Pacific Ocean' }, + crossesAntimeridian: true }, { start: { x: -74.564208984375, y: -0.17578097424708533 }, end: { x: 137.779541015625, y: -22.75592068148639 }, - properties: { name: 'south 1' } + properties: { name: 'Colombia/Peru border → Northern Territory, Australia' }, + crossesAntimeridian: true }, { start: { x: -66.829833984375, y: -18.81271785640776 }, end: { x: 118.795166015625, y: -20.797201434306984 }, - properties: { name: 'south 2' } + properties: { name: 'Challapata, Bolivia → Western Australia, Australia' }, + crossesAntimeridian: true } ]; +// Exact snapshots for non-crossing routes only. +// Splitting correctness for crossing routes (indices 2–5) is owned by antimeridian.test.ts. +// Integration tests verify output format and property pass-through. const expectedArcs = [ { - "properties": { "name": "Seattle to DC" }, + "properties": { "name": "Seattle → DC" }, "geometries": [{ "coords": [ [-122, 48], @@ -53,7 +64,7 @@ const expectedArcs = [ }] }, { - "properties": { "name": "Seattle to London" }, + "properties": { "name": "Seattle → London" }, "geometries": [{ "coords": [ [-122, 48], @@ -62,61 +73,12 @@ const expectedArcs = [ ], "length": 3 }] - }, - { - "properties": { "name": "crosses dateline 1" }, - "geometries": [{ - "coords": [ - [-75.9375, 35.46067], - [-136.823034, -10.367409], - [146.25, -43.068888] - ], - "length": 3 - }] - }, - { - "properties": { "name": "crosses dateline 2" }, - "geometries": [{ - "coords": [ - [145.546875, 48.458352], - [-157.284841, 8.442054], - [-112.5, -37.71859] - ], - "length": 3 - }] - }, - { - "properties": { "name": "south 1" }, - "geometries": [{ - "coords": [ - [-74.564209, -0.175781], - [-140.443271, -35.801086], - [137.779541, -22.755921] - ], - "length": 3 - }] - }, - { - "properties": { "name": "south 2" }, - "geometries": [{ - "coords": [ - [-66.829834, -18.812718], - [-146.781778, -82.179503], - [118.795166, -20.797201] - ], - "length": 3 - }] } ]; -// Expected WKT results (precise values for regression testing) const expectedWkts = [ 'LINESTRING(-122 48,-97.728086 45.753682,-77 39)', 'LINESTRING(-122 48,-64.165901 67.476242,0 51)', - 'LINESTRING(-75.9375 35.46067,-136.823034 -10.367409,146.25 -43.068888)', - 'LINESTRING(145.546875 48.458352,-157.284841 8.442054,-112.5 -37.71859)', - 'LINESTRING(-74.564209 -0.175781,-140.443271 -35.801086,137.779541 -22.755921)', - 'LINESTRING(-66.829834 -18.812718,-146.781778 -82.179503,118.795166 -20.797201)', ]; describe('Integration', () => { @@ -125,12 +87,20 @@ describe('Integration', () => { test(`Route ${idx} (${route.properties.name}) should match expected output`, () => { const gc = new GreatCircle(route.start, route.end, route.properties); const line = gc.Arc(3); - - // Test internal structure matches expected - expect(JSON.stringify(line)).toEqual(JSON.stringify(expectedArcs[idx])); - - // Test WKT output matches expected - expect(line.wkt()).toBe(expectedWkts[idx]); + + if (!route.crossesAntimeridian) { + // Non-crossing routes: exact snapshot (LineString structure is stable) + expect(JSON.stringify(line)).toEqual(JSON.stringify(expectedArcs[idx])); + expect(line.wkt()).toBe(expectedWkts[idx]); + } else { + // Crossing routes: verify output format and property pass-through only. + // Splitting correctness (MultiLineString, ±180 boundaries) is in antimeridian.test.ts. + const geojson = line.json(); + expect(geojson.type).toBe('Feature'); + expect(geojson.properties).toEqual(route.properties); + // WKT serializer must produce two LINESTRING parts for split routes + expect(line.wkt()).toContain('; '); + } }); }); }); @@ -141,14 +111,12 @@ describe('Integration', () => { const gc = new GreatCircle(route.start, route.end, route.properties); const line = gc.Arc(3); const geojson = line.json(); - - // Validate GeoJSON structure + expect(geojson.type).toBe('Feature'); expect(geojson.geometry).toBeDefined(); expect(geojson.properties).toBeDefined(); expect(geojson.properties).toEqual(route.properties); - - // Validate coordinates exist and are array + expect('coordinates' in geojson.geometry).toBe(true); const coords = (geojson.geometry as any).coordinates; expect(Array.isArray(coords)).toBe(true); @@ -157,40 +125,25 @@ describe('Integration', () => { }); }); - describe('Dateline crossing behavior', () => { - const datelineCrossingRoutes = routes.filter(route => - route.properties.name.includes('crosses dateline') + describe('Southern hemisphere routes', () => { + const southernRoutes = routes.filter(route => + route.start.y < 0 || route.end.y < 0 ); - datelineCrossingRoutes.forEach((route, idx) => { - test(`${route.properties.name} should handle dateline crossing`, () => { + southernRoutes.forEach((route) => { + test(`${route.properties.name} should produce coordinates with southern latitudes`, () => { const gc = new GreatCircle(route.start, route.end, route.properties); const line = gc.Arc(3); - - expect(line.geometries.length).toBeGreaterThan(0); - - const coords = (line.json().geometry as any).coordinates; - expect(coords.length).toBeGreaterThan(0); - }); - }); - }); - describe('Southern hemisphere routes', () => { - const southernRoutes = routes.filter(route => - route.properties.name.includes('south') - ); + // Flatten MultiLineString coordinates before checking for southern latitudes. + // Without flattening, coords.some() iterates over number[][] (sub-arrays), + // not number[] (individual points), so coord[1] would be an array, not a latitude. + const geojson = line.json(); + const allCoords: number[][] = geojson.geometry.type === 'MultiLineString' + ? (geojson.geometry as MultiLineString).coordinates.flat() + : (geojson.geometry as LineString).coordinates; - southernRoutes.forEach((route, idx) => { - test(`${route.properties.name} should handle southern hemisphere`, () => { - const gc = new GreatCircle(route.start, route.end, route.properties); - const line = gc.Arc(3); - - expect(line.geometries.length).toBeGreaterThan(0); - - // Check that some coordinates have southern latitudes - const coords = (line.json().geometry as any).coordinates; - expect(Array.isArray(coords)).toBe(true); - const hasSouthernLatitudes = coords.some((coord: number[]) => { + const hasSouthernLatitudes = allCoords.some((coord: number[]) => { return Array.isArray(coord) && coord.length > 1 && typeof coord[1] === 'number' && coord[1] < 0; }); expect(hasSouthernLatitudes).toBe(true); @@ -200,20 +153,17 @@ describe('Integration', () => { describe('Full workflow test', () => { test('should complete full workflow from coordinates to output formats', () => { - const testRoute = routes[0]!; // Seattle to DC - non-null assertion since we know it exists - + const testRoute = routes[0]!; // Seattle → DC + const gc = new GreatCircle(testRoute.start, testRoute.end, testRoute.properties); const line = gc.Arc(3); - - // Test Arc instance + expect(line).toBeDefined(); expect(line.properties).toEqual(testRoute.properties); - - // Test GeoJSON output + const geojson = line.json(); expect(geojson.type).toBe('Feature'); - - // Test WKT output + const wkt = line.wkt(); expect(typeof wkt).toBe('string'); expect(wkt.startsWith('LINESTRING')).toBe(true); From a27ad9141ad3d6ef36464ef7ff70cf8e4276a42f Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 9 Apr 2026 15:22:21 -0700 Subject: [PATCH 5/5] chore: remove GDAL attribution now that heuristic is replaced The GDAL-ported dateline splitting heuristic was removed when the analytical bisection approach was introduced. No remaining code derives from GDAL, so delete GDAL-LICENSE.md, remove it from the package.json files list, drop the file-level attribution block in great-circle.ts, and remove the GDAL references from README.md. --- GDAL-LICENSE.md | 57 --------------------------------------------- README.md | 6 +---- package.json | 3 +-- src/great-circle.ts | 9 ------- 4 files changed, 2 insertions(+), 73 deletions(-) delete mode 100644 GDAL-LICENSE.md diff --git a/GDAL-LICENSE.md b/GDAL-LICENSE.md deleted file mode 100644 index 1381730..0000000 --- a/GDAL-LICENSE.md +++ /dev/null @@ -1,57 +0,0 @@ -# GDAL License Attribution - -This project includes code ported from the GDAL (Geospatial Data Abstraction Library) project, specifically from the OGR library. - -## GDAL License - -GDAL is licensed under the MIT/X11 license. The following license text applies to the GDAL code portions used in this project: - -``` -Copyright (c) 2000, Frank Warmerdam -Copyright (c) 2008-2014, Even Rouault -Copyright (c) 2015, Faza Mahamood -Copyright (c) 2016, Ari Jolma -Copyright (c) 2017, Ari Jolma -Copyright (c) 2018, Ari Jolma -Copyright (c) 2019, Ari Jolma -Copyright (c) 2020, Ari Jolma -Copyright (c) 2021, Ari Jolma -Copyright (c) 2022, Ari Jolma -Copyright (c) 2023, Ari Jolma -Copyright (c) 2024, Ari Jolma - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -``` - -## Ported Code - -The following files contain code ported from GDAL: - -- `src/great-circle.ts` - Dateline handling logic ported from `gdal/ogr/ogrgeometryfactory.cpp` - -## Original Source - -- **GDAL Repository**: https://github.com/OSGeo/gdal -- **Specific File**: `gdal/ogr/ogrgeometryfactory.cpp` -- **Commit Reference**: 7bfb9c452a59aac958bff0c8386b891edf8154ca -- **GDAL Website**: https://gdal.org/ - -## Modifications - -The ported code has been adapted from C++ to TypeScript and integrated into the arc.js library's great circle calculation functionality. The core dateline handling algorithm remains functionally equivalent to the original GDAL implementation. diff --git a/README.md b/README.md index 7840aee..00d201e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Calculate great circle routes as lines in GeoJSON or WKT format. - Works in Node.js and browsers - Generates GeoJSON and WKT output formats - Handles dateline crossing automatically -- Based on [Ed Williams' Aviation Formulary](https://edwilliams.org/avform.htm#Intermediate) algorithms and the GDAL source code +- Based on [Ed Williams' Aviation Formulary](https://edwilliams.org/avform.htm#Intermediate) algorithms ## Installation @@ -157,7 +157,3 @@ arc.js powers the [`greatCircle`](https://turfjs.org/docs/api/greatCircle) funct ## License This project is licensed under the BSD license. See [LICENSE.md](LICENSE) for details. - -### Third-Party Licenses - -This project includes code ported from GDAL (Geospatial Data Abstraction Library), which is licensed under the MIT/X11 license. See [GDAL-LICENSE.md](GDAL-LICENSE.md) for the full GDAL license text and attribution details. diff --git a/package.json b/package.json index 3120ade..fe247c8 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,7 @@ "dist/", "README.md", "LICENSE.md", - "GDAL-LICENSE.md", - "CHANGELOG.md" +"CHANGELOG.md" ], "engines": { "node": ">=18" diff --git a/src/great-circle.ts b/src/great-circle.ts index 287c601..72442bb 100644 --- a/src/great-circle.ts +++ b/src/great-circle.ts @@ -4,15 +4,6 @@ import { Arc } from './arc.js'; import { _LineString } from './line-string.js'; import { roundCoords, R2D } from './utils.js'; -/* - * Portions of this file contain code ported from GDAL (Geospatial Data Abstraction Library) - * - * GDAL is licensed under the MIT/X11 license. - * See GDAL-LICENSE.md for the full license text. - * - * Original source: gdal/ogr/ogrgeometryfactory.cpp - * Repository: https://github.com/OSGeo/gdal - */ /** * Great Circle calculation class