From 5f58c4ff5ce306a7617c148f23ce8d19a6ce6fab Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sun, 29 Mar 2026 18:34:46 -0700 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 From 807167d85021686bd7d384ede1b039ad3795251d Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 9 Apr 2026 15:23:57 -0700 Subject: [PATCH 06/15] docs: fix stale ArcOptions.offset JSDoc and DEVELOPING.md scripts Mark ArcOptions.offset as @deprecated no-op. Remove non-existent test:build / test:all scripts and CJS/UMD bundle references from DEVELOPING.md. --- DEVELOPING.md | 52 +++++++++++++++------------------------------------ src/types.ts | 11 ++++++----- 2 files changed, 21 insertions(+), 42 deletions(-) diff --git a/DEVELOPING.md b/DEVELOPING.md index 3841fb8..9616ce2 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -6,9 +6,8 @@ This guide covers working with the TypeScript codebase for arc.js. ```bash npm install # Install dependencies -npm run build # Build all outputs +npm run build # Build ESM output npm test # Run TypeScript tests -npm run test:all # Run all tests (TypeScript + build validation) ``` ## Project Structure @@ -24,8 +23,7 @@ src/ └── types.ts # TypeScript type definitions test/ -├── *.test.ts # Jest TypeScript tests (source code) -└── build-output.test.js # Build validation (compiled output) +└── *.test.ts # Jest TypeScript tests ``` ## Development Workflow @@ -36,14 +34,11 @@ test/ # Run TypeScript tests (fast, for development) npm test -# Run build validation (slower, tests compiled output) -npm run test:build - -# Run everything (recommended before committing) -npm run test:all - # Watch mode for development npm run test:watch + +# Coverage report +npm run test:coverage ``` ### Building @@ -52,10 +47,7 @@ npm run test:watch npm run build ``` -This generates: -- `dist/` - CommonJS output with `.d.ts` files -- `dist/esm/` - ES modules output -- `arc.js` - Browser bundle (UMD format) +This generates `dist/` — ESM output with `.d.ts` declaration files. ## Publishing @@ -68,7 +60,7 @@ This generates: ### Pre-publish Checklist (for maintainers) -1. **Tests pass**: `npm run test:all` +1. **Tests pass**: `npm test` 2. **Build succeeds**: `npm run build` 3. **Version updated**: Update `package.json` version 4. **Changelog updated**: Document changes @@ -77,16 +69,12 @@ This generates: ### Publishing Process (maintainers only) ```bash -npm run build # Builds automatically on prepublishOnly -npm publish +npm publish # prepublishOnly runs npm run build automatically ``` -The `prepublishOnly` script ensures a fresh build before publishing. - ### What Gets Published -- `dist/` folder (compiled JS + TypeScript definitions) -- `arc.js` browser bundle +- `dist/` folder (compiled ESM JS + TypeScript definitions) - `README.md`, `LICENSE.md`, `CHANGELOG.md` ## TypeScript Development @@ -94,33 +82,23 @@ The `prepublishOnly` script ensures a fresh build before publishing. ### TypeScript Configuration - **Source**: Modern TypeScript with strict settings -- **Output**: ES2022 for broad compatibility -- **Paths**: `@/` alias maps to `src/` in tests +- **Output**: ES2022, ESM only - **Declarations**: Full `.d.ts` generation for consumers + ### Adding New Types -1. Add interfaces/types to `src/types.ts`. You can see that it makes use of some GeoJSON types, but in the future it may want to use more of them. +1. Add interfaces/types to `src/types.ts` 2. Export public types from `src/index.ts` 3. Import types with `import type { ... }` -4. Add tests in relevant `test/*.test.ts` files including typescript.test.ts - -## Usage & Module Formats +4. Add tests in relevant `test/*.test.ts` files including `typescript.test.ts` -The package supports multiple import styles: +## Usage ```javascript -// CommonJS (Node.js) -const { GreatCircle } = require('arc'); - -// ES Modules +// ES Modules (Node.js or bundler) import { GreatCircle } from 'arc'; - -// Browser (UMD bundle) - ``` -All formats are tested in `test/build-output.test.js`. - ## Common Tasks ```bash diff --git a/src/types.ts b/src/types.ts index 2e7942e..987241e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,11 +20,12 @@ export interface CoordinatePoint { * Options for Arc generation */ export interface ArcOptions { - /** - * Offset from dateline in degrees (default: 10) - * Controls the likelihood that lines will be split which cross the dateline. - * The higher the number the more likely. Lines within this many degrees - * of the dateline will be split. + /** + * @deprecated No-op. Retained for backwards compatibility. + * + * Previously controlled the dateline offset threshold used by the GDAL-ported + * heuristic. The heuristic has since been replaced with an analytical bisection + * approach — this field has no effect on output. */ offset?: number; } From 98d42e7601fbbb77cba4f4aabb3d625610b2e3e8 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 9 Apr 2026 15:29:36 -0700 Subject: [PATCH 07/15] chore: remove deprecated offset usage from tests, README, and demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip { offset } from all Arc() call sites — the field is a no-op since the GDAL heuristic was replaced. Keep the backwards-compat type assertion in typescript.test.ts with an explanatory comment. Update README Dateline Crossing section and remove the offset UI control from index.html. --- README.md | 8 +++--- index.html | 14 ++-------- test/great-circle.test.ts | 16 ++++++------ test/typescript.test.ts | 54 ++++++++++++++++++++------------------- 4 files changed, 41 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 00d201e..3aca534 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,11 @@ const gc = new GreatCircle(start, end, { name: 'Seattle to DC' }); #### 3. Generate the arc ```js -const line = gc.Arc(100, { offset: 10 }); +const line = gc.Arc(100); ``` **Parameters:** - `npoints` (number): Number of intermediate points (higher = more accurate) -- `options.offset` (number): Dateline crossing threshold in degrees (default: 10) ### TypeScript Support @@ -87,8 +86,7 @@ const end: CoordinatePoint = { x: -77, y: 39 }; const properties: RouteProperties = { name: 'Seattle to DC', color: 'blue' }; const gc = new GreatCircle(start, end, properties); -const options: ArcOptions = { offset: 10 }; -const line = gc.Arc(100, options); +const line = gc.Arc(100); // Fully typed return values const geojson = line.json(); // GeoJSONFeature @@ -144,7 +142,7 @@ const wkt = line.wkt(); ### Dateline Crossing -The library automatically handles routes that cross the international dateline. The `offset` option (default: 10) controls how close to the dateline a route must be before it gets split into multiple segments. For routes near the poles, you may need a higher offset value. +Routes that cross the international dateline are automatically detected and split into a `MultiLineString` with exact `±180°` boundary points. No configuration is needed. ## Examples diff --git a/index.html b/index.html index a6787c6..bccd9c9 100644 --- a/index.html +++ b/index.html @@ -333,11 +333,6 @@

Settings

-
- - -
-
@@ -377,7 +372,6 @@

Generated GeoJSON

// Configuration var npoints = 100; - var offset = 20; var coords = []; var points = []; var snap_tolerance = 500000; @@ -390,10 +384,6 @@

Generated GeoJSON

npoints = parseInt(this.value) || 100; }); - document.getElementById('offset').addEventListener('change', function() { - offset = parseInt(this.value) || 20; - }); - var start, end; function draw(coords) { @@ -469,7 +459,7 @@

Generated GeoJSON

}; var greatCircle = new GreatCircle(from, to, properties); - var gc = greatCircle.Arc(npoints, { offset: offset }); + var gc = greatCircle.Arc(npoints); var line = new L.geoJson().addTo(map); var geojson_feature = gc.json(); @@ -592,7 +582,7 @@

Generated GeoJSON

try { var greatCircle = new GreatCircle(nyc, london, properties); - var gc = greatCircle.Arc(npoints, { offset: offset }); + var gc = greatCircle.Arc(npoints); var line = new L.geoJson().addTo(map); var geojson_feature = gc.json(); diff --git a/test/great-circle.test.ts b/test/great-circle.test.ts index cee7508..9124722 100644 --- a/test/great-circle.test.ts +++ b/test/great-circle.test.ts @@ -159,7 +159,7 @@ describe('GreatCircle', () => { const asia = { x: -170, y: 0 }; const gc = new GreatCircle(pacific, asia); - const arc = gc.Arc(10, { offset: 5 }); + const arc = gc.Arc(10); expect(arc.geometries.length).toBeGreaterThan(0); @@ -168,12 +168,12 @@ describe('GreatCircle', () => { expect(json.type).toBe('Feature'); }); - test('should handle routes near dateline with high offset', () => { + test('should handle routes near dateline', () => { const nearDateline1 = { x: 175, y: 0 }; const nearDateline2 = { x: -175, y: 0 }; - + const gc = new GreatCircle(nearDateline1, nearDateline2); - const arc = gc.Arc(5, { offset: 20 }); + const arc = gc.Arc(5); expect(arc.geometries.length).toBeGreaterThan(0); }); @@ -183,7 +183,7 @@ describe('GreatCircle', () => { const lax = { x: -118.4085, y: 33.9416 }; const gc = new GreatCircle(tokyo, lax); - const json = gc.Arc(100, { offset: 10 }).json(); + const json = gc.Arc(100).json(); expect(json.geometry.type).toBe('MultiLineString'); const coords = (json.geometry as any).coordinates; @@ -206,7 +206,7 @@ describe('GreatCircle', () => { const la = { x: -118.41, y: 33.94 }; const gc = new GreatCircle(auckland, la); - const json = gc.Arc(100, { offset: 10 }).json(); + const json = gc.Arc(100).json(); expect(json.geometry.type).toBe('MultiLineString'); const coords = (json.geometry as any).coordinates; @@ -224,7 +224,7 @@ describe('GreatCircle', () => { const sfo = { x: -122.38, y: 37.62 }; const gc = new GreatCircle(shanghai, sfo); - const json = gc.Arc(100, { offset: 10 }).json(); + const json = gc.Arc(100).json(); expect(json.geometry.type).toBe('MultiLineString'); const coords = (json.geometry as any).coordinates; @@ -242,7 +242,7 @@ describe('GreatCircle', () => { const lax = { x: -118.4085, y: 33.9416 }; const gc = new GreatCircle(tokyo, lax); - const json = gc.Arc(100, { offset: 10 }).json(); + const json = gc.Arc(100).json(); const coords = (json.geometry as any).coordinates; for (const segment of coords) { diff --git a/test/typescript.test.ts b/test/typescript.test.ts index dd3f0a2..ac6aeec 100644 --- a/test/typescript.test.ts +++ b/test/typescript.test.ts @@ -9,8 +9,8 @@ import { expectTypeOf } from 'expect-type'; // Test data with proper TypeScript typing const sanFrancisco: CoordinatePoint = { x: -122.4194, y: 37.7749 }; const newYork: CoordinatePoint = { x: -74.0059, y: 40.7128 }; -const testProperties = { - name: 'TypeScript Test Route', +const testProperties = { + name: 'TypeScript Test Route', id: 'ts-001', metadata: { framework: 'Jest', language: 'TypeScript' } }; @@ -19,17 +19,17 @@ describe('TypeScript', () => { describe('Type inference and safety', () => { test('should infer correct types for Coord class', () => { const coord = new Coord(-122.4194, 37.7749); - + // Test TypeScript type inference for properties expectTypeOf(coord.lon).toEqualTypeOf(); expectTypeOf(coord.lat).toEqualTypeOf(); expectTypeOf(coord.x).toEqualTypeOf(); expectTypeOf(coord.y).toEqualTypeOf(); - + // Test TypeScript type inference for method return types expectTypeOf(coord.view()).toEqualTypeOf(); expectTypeOf(coord.antipode()).toEqualTypeOf(); - + // Runtime validation that types match actual values expect(typeof coord.lon).toBe('number'); expect(typeof coord.view()).toBe('string'); @@ -39,26 +39,25 @@ describe('TypeScript', () => { test('should accept CoordinatePoint interface', () => { // Test interface compatibility and type inference const gc = new GreatCircle(sanFrancisco, newYork, testProperties); - + expectTypeOf(sanFrancisco).toEqualTypeOf(); expectTypeOf(gc).toEqualTypeOf(); - + expect(gc).toBeInstanceOf(GreatCircle); expect(gc.properties).toEqual(testProperties); }); test('should handle optional ArcOptions parameter', () => { const gc = new GreatCircle(sanFrancisco, newYork); - + // Test method overloads - without options const arc1 = gc.Arc(10); expectTypeOf(arc1).toEqualTypeOf(); - - // Test method overloads - with options - const options: ArcOptions = { offset: 15 }; - const arc2 = gc.Arc(10, options); + + // Test method overloads - with options (empty options object; the former `offset` option is deprecated) + const arc2 = gc.Arc(10, {}); expectTypeOf(arc2).toEqualTypeOf(); - + expect(arc1).toBeInstanceOf(Arc); expect(arc2).toBeInstanceOf(Arc); }); @@ -74,9 +73,9 @@ describe('TypeScript', () => { tags: ['arc', 'typescript'], config: { precision: 6, units: 'degrees' } }; - + const arc = new Arc(flexibleProps); - + // Runtime validation that property types are preserved expect(arc.properties.name).toBe('Flexible Route'); expect(arc.properties.count).toBe(42); @@ -89,11 +88,11 @@ describe('TypeScript', () => { const result = new GreatCircle(sanFrancisco, newYork, testProperties) .Arc(25) .json(); - + // Test method chaining type inference expectTypeOf(result).toEqualTypeOf(); expectTypeOf(result.type).toEqualTypeOf<'Feature'>(); - + expect(result.type).toBe('Feature'); expect(result.properties).toEqual(testProperties); }); @@ -105,11 +104,11 @@ describe('TypeScript', () => { const validPoint1: CoordinatePoint = { x: 0, y: 0 }; const validPoint2: CoordinatePoint = { x: -180, y: -90 }; const validPoint3: CoordinatePoint = { x: 180, y: 90 }; - + expect(validPoint1.x).toBe(0); expect(validPoint2.x).toBe(-180); expect(validPoint3.x).toBe(180); - + // TypeScript would catch these at compile time: // const invalid1: CoordinatePoint = { x: 0 }; // Missing y // const invalid2: CoordinatePoint = { y: 0 }; // Missing x @@ -118,11 +117,11 @@ describe('TypeScript', () => { test('should provide proper return type annotations', () => { const gc = new GreatCircle(sanFrancisco, newYork); - + // Test tuple return type inference const interpolated = gc.interpolate(0.5); expectTypeOf(interpolated).toEqualTypeOf<[number, number]>(); - + expect(Array.isArray(interpolated)).toBe(true); expect(interpolated).toHaveLength(2); expect(typeof interpolated[0]).toBe('number'); @@ -136,12 +135,12 @@ describe('TypeScript', () => { expect(typeof Coord).toBe('function'); expect(typeof GreatCircle).toBe('function'); expect(typeof Arc).toBe('function'); - + // Test that imported classes are usable constructors const coord = new Coord(0, 0); const gc = new GreatCircle(sanFrancisco, newYork); const arc = new Arc(); - + expect(coord).toBeInstanceOf(Coord); expect(gc).toBeInstanceOf(GreatCircle); expect(arc).toBeInstanceOf(Arc); @@ -149,13 +148,16 @@ describe('TypeScript', () => { test('should handle type-only imports correctly', () => { // Test type-only imports (compile-time only, no runtime footprint) - + const point: CoordinatePoint = { x: 1, y: 2 }; + // offset is @deprecated and a no-op at runtime. This assertion exists solely to + // verify the field remains on ArcOptions for backwards compatibility — callers + // passing { offset } must not get a TypeScript compile error. const options: ArcOptions = { offset: 10 }; - + expect(point.x).toBe(1); expect(options.offset).toBe(10); - + // Type-only imports don't create runtime values // (Can only validate the objects that use these types work correctly) expect(point).toBeDefined(); From 496efc636e4b491e5de24ea0cf078f318fa0b674 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sat, 11 Apr 2026 11:09:33 -0700 Subject: [PATCH 08/15] feat(great-circle): default npoints to 100 Arc() with no argument previously returned a 2-point LineString (the <= 2 fallback). A default of 100 produces a usable great circle arc out of the box, consistent with the library's intent. --- src/great-circle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/great-circle.ts b/src/great-circle.ts index 72442bb..60002ec 100644 --- a/src/great-circle.ts +++ b/src/great-circle.ts @@ -89,7 +89,7 @@ export class GreatCircle { * console.log(greatCircle.Arc(10)); // Arc { geometries: [ [Array] ] } * ``` */ - Arc(npoints?: number, _options?: ArcOptions): Arc { + Arc(npoints: number = 100, _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 From 6e9178d1148854c3ff47bc4de40ba8de04e34da1 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sat, 11 Apr 2026 11:11:39 -0700 Subject: [PATCH 09/15] refactor(tests): extract shared route fixtures to test/fixtures/ Consolidates inline coordinate constants, route arrays, and test property helpers into a single source of truth. All test files now import from test/fixtures/routes.ts, eliminating duplication and making fixture coverage reviewable in one place. --- test/antimeridian.test.ts | 94 +++++++++++---------- test/fixtures/routes.ts | 171 ++++++++++++++++++++++++++++++++++++++ test/great-circle.test.ts | 158 +++++++++++++---------------------- test/integration.test.ts | 60 ++----------- test/typescript.test.ts | 7 +- 5 files changed, 287 insertions(+), 203 deletions(-) create mode 100644 test/fixtures/routes.ts diff --git a/test/antimeridian.test.ts b/test/antimeridian.test.ts index 61f5b9e..b806de5 100644 --- a/test/antimeridian.test.ts +++ b/test/antimeridian.test.ts @@ -1,39 +1,10 @@ import { GreatCircle } from '../src'; import type { MultiLineString, LineString } from 'geojson'; +import { SPLIT_NPOINTS, EAST_TO_WEST, WEST_TO_EAST, SOUTH_TO_SOUTH_E_TO_W, SOUTH_TO_SOUTH_W_TO_E, HIGH_LATITUDE, NON_CROSSING } from './fixtures/routes.js'; -// 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 } }, -]; - -// 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 } }, -]; - -// 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 } }, -]; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- function assertSplitAtAntimeridian(coords: number[][][], fromEast: boolean) { // Exactly 2 segments — guards against false positives from 3+ segment splits @@ -61,6 +32,10 @@ function assertSplitAtAntimeridian(coords: number[][][], fromEast: boolean) { expect(lastOfFirst[1] ?? NaN).toBeCloseTo(firstOfSecond[1] ?? NaN, 3); } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + describe('antimeridian splitting — east to west', () => { for (const npoints of SPLIT_NPOINTS) { describe(`npoints=${npoints}`, () => { @@ -117,6 +92,33 @@ describe('antimeridian splitting — south to south, west to east', () => { } }); +describe('antimeridian splitting — npoints edge cases', () => { + // npoints=3 is the smallest value that triggers the bisection path. + // Reuses EAST_TO_WEST — direction symmetry means one direction is sufficient here. + describe('npoints=3 still splits correctly', () => { + for (const { name, start, end } of EAST_TO_WEST) { + test(`${name}`, () => { + const result = new GreatCircle(start, end).Arc(3).json(); + expect(result.geometry.type).toBe('MultiLineString'); + assertSplitAtAntimeridian((result.geometry as MultiLineString).coordinates, true); + }); + } + }); + + describe('npoints=2 returns LineString (intentional limitation)', () => { + // With only 2 points (start + end), the bisection path is skipped. + // Renderers that understand coordinate wrapping (e.g. MapLibre GL JS) handle + // [[139.78, 35.55], [-118.41, 33.94]] correctly as a Pacific arc. Splitting + // into two disconnected stubs with no curvature would be worse. See Arc() comment. + for (const { name, start, end } of EAST_TO_WEST) { + test(`${name}`, () => { + const result = new GreatCircle(start, end).Arc(2).json(); + expect(result.geometry.type).toBe('LineString'); + }); + } + }); +}); + describe('high-latitude routes', () => { for (const { name, start, end } of HIGH_LATITUDE) { test(`${name} produces valid GeoJSON with no large longitude jumps`, () => { @@ -138,16 +140,18 @@ describe('high-latitude routes', () => { }); 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); - } - }); + for (const { name, start, end, maxJump } of NON_CROSSING) { + test(`${name} returns a LineString with no large longitude jumps`, () => { + const result = new GreatCircle(start, end).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(maxJump); + } + }); + } }); diff --git a/test/fixtures/routes.ts b/test/fixtures/routes.ts new file mode 100644 index 0000000..010a297 --- /dev/null +++ b/test/fixtures/routes.ts @@ -0,0 +1,171 @@ +/** + * Shared test fixtures: named coordinate points, route arrays, and factory helpers. + * All coordinates are [longitude, latitude] in decimal degrees (WGS84). + * + * Run `node scripts/dump-fixtures.mjs` to export routes as GeoJSON for + * visual verification at geojson.io. + */ + +// --------------------------------------------------------------------------- +// Named coordinate points +// --------------------------------------------------------------------------- + +/** Generic origin used in unit tests that don't need a real location. */ +export const ORIGIN = { x: 0, y: 0 }; + +/** Generic second point 10° east of the origin - paired with ORIGIN for unit tests. */ +export const TEN_EAST = { x: 10, y: 0 }; + +/** Seattle, WA - used in non-crossing domestic route tests. */ +export const SEATTLE = { x: -122, y: 48 }; + +/** Washington, DC - used in non-crossing domestic route tests. */ +export const DC = { x: -77, y: 39 }; + +/** San Francisco, CA (precise) - used in TypeScript type tests. */ +export const SAN_FRANCISCO = { x: -122.4194, y: 37.7749 }; + +/** New York, NY (precise) - used in TypeScript type tests. */ +export const NEW_YORK = { x: -74.0059, y: 40.7128 }; + +// --------------------------------------------------------------------------- +// Antipodal pair - GreatCircle constructor must throw for these +// --------------------------------------------------------------------------- +export const ANTIPODAL = { + start: { x: 1, y: 1 }, + end: { x: -179, y: -1 }, + expectedError: "it appears 1,1 and -179,-1 are 'antipodal', e.g diametrically opposite, thus there is no single route but rather infinite", +}; + +// --------------------------------------------------------------------------- +// Test property factory +// Generates a default { name, color } properties object for route tests. +// Pass overrides to vary specific fields: makeProps({ color: 'blue' }) +// --------------------------------------------------------------------------- +export function makeProps(overrides: Record = {}): Record { + return { name: 'Test Route', color: 'red', ...overrides }; +} + +// --------------------------------------------------------------------------- +// Route fixture types +// --------------------------------------------------------------------------- + +export interface RouteFixture { + name: string; + start: { x: number; y: number }; + end: { x: number; y: number }; +} + +export interface NonCrossingFixture extends RouteFixture { + /** Maximum allowed longitude difference (°) between consecutive sampled points. + * Tight bound (e.g. 20) for short routes; <180 for intercontinental routes where + * any jump ≥180 would indicate a spurious antimeridian split. */ + maxJump: number; +} + +export interface IntegrationRouteFixture extends RouteFixture { + properties: { name: string }; + crossesAntimeridian: boolean; +} + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- +export const SPLIT_NPOINTS = [10, 100] as const; + +// --------------------------------------------------------------------------- +// Antimeridian-crossing routes +// --------------------------------------------------------------------------- + +// East-to-west Pacific crossings (positive → negative longitude) +// Note: Auckland → LAX also covers the south-to-north hemisphere case. +export const EAST_TO_WEST: RouteFixture[] = [ + { 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) +export const WEST_TO_EAST: RouteFixture[] = [ + { 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) +export const SOUTH_TO_SOUTH_E_TO_W: RouteFixture[] = [ + { name: 'Sydney → Buenos Aires', start: { x: 151.21, y: -33.87 }, end: { x: -58.38, y: -34.60 } }, +]; + +export const SOUTH_TO_SOUTH_W_TO_E: RouteFixture[] = [ + { 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 (may or may not cross antimeridian) +// --------------------------------------------------------------------------- +export const HIGH_LATITUDE: RouteFixture[] = [ + { 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 } }, +]; + +// --------------------------------------------------------------------------- +// Non-crossing routes - should always produce LineString +// --------------------------------------------------------------------------- +export const NON_CROSSING: NonCrossingFixture[] = [ + { name: 'Seattle → DC', start: { x: -122.0, y: 48.0 }, end: { x: -77.0, y: 39.0 }, maxJump: 20 }, + { name: 'NYC → London', start: { x: -74.0, y: 40.71 }, end: { x: -0.13, y: 51.51 }, maxJump: 180 }, + { name: 'NYC → Paris', start: { x: -74.0, y: 40.71 }, end: { x: 2.35, y: 48.85 }, maxJump: 180 }, + { name: 'Lagos → Colombo', start: { x: 3.4, y: 6.5 }, end: { x: 79.9, y: 6.9 }, maxJump: 180 }, +]; + +// --------------------------------------------------------------------------- +// Integration test routes - real-world routes covering format/property pass-through. +// Splitting correctness for crossing routes is owned by antimeridian.test.ts. +// --------------------------------------------------------------------------- +export const INTEGRATION_ROUTES: IntegrationRouteFixture[] = [ + { + start: { x: -122, y: 48 }, + end: { x: -77, y: 39 }, + properties: { name: 'Seattle → DC' }, + crossesAntimeridian: false, + name: 'Seattle → DC', + }, + { + start: { x: -122, y: 48 }, + end: { x: 0, y: 51 }, + properties: { name: 'Seattle → London' }, + crossesAntimeridian: false, + name: 'Seattle → London', + }, + { + start: { x: -75.9375, y: 35.460669951495305 }, + end: { x: 146.25, y: -43.06888777416961 }, + properties: { name: 'Pamlico Sound, NC, USA → Tasmania, Australia' }, + crossesAntimeridian: true, + name: 'Pamlico Sound, NC, USA → Tasmania, Australia', + }, + { + start: { x: 145.54687500000003, y: 48.45835188280866 }, + end: { x: -112.5, y: -37.71859032558814 }, + properties: { name: 'Sea of Okhotsk, Russia → Southern Pacific Ocean' }, + crossesAntimeridian: true, + name: 'Sea of Okhotsk, Russia → Southern Pacific Ocean', + }, + { + start: { x: -74.564208984375, y: -0.17578097424708533 }, + end: { x: 137.779541015625, y: -22.75592068148639 }, + properties: { name: 'Colombia/Peru border → Northern Territory, Australia' }, + crossesAntimeridian: true, + name: 'Colombia/Peru border → Northern Territory, Australia', + }, + { + start: { x: -66.829833984375, y: -18.81271785640776 }, + end: { x: 118.795166015625, y: -20.797201434306984 }, + properties: { name: 'Challapata, Bolivia → Western Australia, Australia' }, + crossesAntimeridian: true, + name: 'Challapata, Bolivia → Western Australia, Australia', + }, +]; diff --git a/test/great-circle.test.ts b/test/great-circle.test.ts index 9124722..5f56af2 100644 --- a/test/great-circle.test.ts +++ b/test/great-circle.test.ts @@ -1,25 +1,11 @@ import { Arc, GreatCircle } from '../src'; - -// Common test coordinates -const startPoint = { x: 0, y: 0 }; -const endPoint = { x: 10, y: 0 }; -const seattleCoords = { x: -122, y: 48 }; -const dcCoords = { x: -77, y: 39 }; - -// Common test properties -const testRouteProps = { name: 'Test Route', color: 'red' }; - -// Antipodal test coordinates (should throw error) -const antipodal1 = { x: 1, y: 1 }; -const antipodal2 = { x: -179, y: -1 }; - -const expectedAntipodalError = "it appears 1,1 and -179,-1 are 'antipodal', e.g diametrically opposite, thus there is no single route but rather infinite"; +import { ORIGIN, TEN_EAST, SEATTLE, DC, ANTIPODAL, makeProps, EAST_TO_WEST } from './fixtures/routes.js'; describe('GreatCircle', () => { describe('Basic construction and interpolation', () => { test('should create GreatCircle and interpolate a start and end point', () => { - const gc = new GreatCircle(startPoint, endPoint); - + const gc = new GreatCircle(ORIGIN, TEN_EAST); + expect(gc).toBeDefined(); expect(gc.interpolate(0)).toEqual([0, 0]); expect(gc.interpolate(1)).toEqual([10, 0]); @@ -28,23 +14,22 @@ describe('GreatCircle', () => { describe('Constructor with properties', () => { test('should set properties correctly', () => { - // Clone props to avoid test pollution - const props = { ...testRouteProps }; - const gc = new GreatCircle(seattleCoords, dcCoords, props); - + const props = makeProps(); + const gc = new GreatCircle(SEATTLE, DC, props); + expect(gc.properties).toEqual(props); }); }); describe('Interpolation at midpoint', () => { test('should calculate midpoint correctly', () => { - const gc = new GreatCircle(seattleCoords, dcCoords); + const gc = new GreatCircle(SEATTLE, DC); const midpoint = gc.interpolate(0.5); - + expect(midpoint).toHaveLength(2); expect(typeof midpoint[0]).toBe('number'); expect(typeof midpoint[1]).toBe('number'); - + // Midpoint should be between start and end expect(midpoint[0]).toBeGreaterThan(-122); expect(midpoint[0]).toBeLessThan(-77); @@ -55,28 +40,27 @@ describe('GreatCircle', () => { describe('Arc generation', () => { test('should return Arc instance', () => { - const gc = new GreatCircle(seattleCoords, dcCoords); + const gc = new GreatCircle(SEATTLE, DC); const generatedArc = gc.Arc(3); - + expect(generatedArc).toBeInstanceOf(Arc); }); test('should generate geometries', () => { - const gc = new GreatCircle(seattleCoords, dcCoords); + const gc = new GreatCircle(SEATTLE, DC); const generatedArc = gc.Arc(3); - + expect(generatedArc.geometries.length).toBeGreaterThan(0); }); test('should produce valid GeoJSON Feature with coordinates', () => { - const gc = new GreatCircle(seattleCoords, dcCoords); + const gc = new GreatCircle(SEATTLE, DC); const generatedArc = gc.Arc(3); - + const json = generatedArc.json(); expect(json.type).toBe('Feature'); expect(json.geometry).toBeDefined(); - - // Check that coordinates exist and have length + expect('coordinates' in json.geometry).toBe(true); const coords = (json.geometry as any).coordinates; expect(Array.isArray(coords)).toBe(true); @@ -87,131 +71,111 @@ describe('GreatCircle', () => { describe('GreatCircleException: Antipodal points', () => { test('should throw error for antipodal points', () => { expect(() => { - new GreatCircle(antipodal1, antipodal2); - }).toThrow(expectedAntipodalError); + new GreatCircle(ANTIPODAL.start, ANTIPODAL.end); + }).toThrow(ANTIPODAL.expectedError); }); }); describe('Input validation', () => { test('should validate start point', () => { expect(() => { - new GreatCircle(null as any, endPoint); + new GreatCircle(null as any, TEN_EAST); }).toThrow(/expects two args/); }); test('should validate end point', () => { expect(() => { - new GreatCircle(startPoint, null as any); + new GreatCircle(ORIGIN, null as any); }).toThrow(/expects two args/); }); test('should validate start point with undefined x', () => { expect(() => { - new GreatCircle({ x: undefined, y: 0 } as any, endPoint); + new GreatCircle({ x: undefined, y: 0 } as any, TEN_EAST); }).toThrow(/expects two args/); }); test('should validate end point with undefined y', () => { expect(() => { - new GreatCircle(startPoint, { x: 0, y: undefined } as any); + new GreatCircle(ORIGIN, { x: 0, y: undefined } as any); }).toThrow(/expects two args/); }); }); describe('Arc generation edge cases', () => { test('should handle npoints <= 2', () => { - const gc = new GreatCircle(seattleCoords, dcCoords); + const gc = new GreatCircle(SEATTLE, DC); const arc = gc.Arc(2); - + expect(arc.geometries).toHaveLength(1); expect(arc.geometries[0]?.coords).toHaveLength(2); }); test('should handle npoints = 0', () => { - const gc = new GreatCircle(seattleCoords, dcCoords); + const gc = new GreatCircle(SEATTLE, DC); const arc = gc.Arc(0); - + expect(arc.geometries).toHaveLength(1); expect(arc.geometries[0]?.coords).toHaveLength(2); }); test('should handle npoints = 1', () => { - const gc = new GreatCircle(seattleCoords, dcCoords); + const gc = new GreatCircle(SEATTLE, DC); const arc = gc.Arc(1); - + expect(arc.geometries).toHaveLength(1); expect(arc.geometries[0]?.coords).toHaveLength(2); }); - test('should handle undefined npoints', () => { - const gc = new GreatCircle(seattleCoords, dcCoords); + test('should default to 100 points when npoints is undefined', () => { + const gc = new GreatCircle(SEATTLE, DC); const arc = gc.Arc(undefined as any); - + expect(arc.geometries).toHaveLength(1); - expect(arc.geometries[0]?.coords).toHaveLength(2); + expect(arc.geometries[0]?.coords).toHaveLength(100); }); }); describe('Dateline crossing', () => { test('should handle routes that cross the dateline', () => { - // Route from Pacific to Asia that crosses dateline - const pacific = { x: 170, y: 0 }; - const asia = { x: -170, y: 0 }; - - const gc = new GreatCircle(pacific, asia); + // Generic equatorial crossing — not a named fixture (synthetic boundary test) + const gc = new GreatCircle({ x: 170, y: 0 }, { x: -170, y: 0 }); const arc = gc.Arc(10); - + expect(arc.geometries.length).toBeGreaterThan(0); - - // Should potentially create multiple LineStrings for dateline crossing - const json = arc.json(); - expect(json.type).toBe('Feature'); + expect(arc.json().type).toBe('Feature'); }); test('should handle routes near dateline', () => { - const nearDateline1 = { x: 175, y: 0 }; - const nearDateline2 = { x: -175, y: 0 }; - - const gc = new GreatCircle(nearDateline1, nearDateline2); + const gc = new GreatCircle({ x: 175, y: 0 }, { x: -175, y: 0 }); const arc = gc.Arc(5); - + expect(arc.geometries.length).toBeGreaterThan(0); }); test('should split Tokyo-LAX route at antimeridian with shared crossing point', () => { - const tokyo = { x: 139.7798, y: 35.5494 }; - const lax = { x: -118.4085, y: 33.9416 }; - - const gc = new GreatCircle(tokyo, lax); - const json = gc.Arc(100).json(); - + const { start: tokyo, end: lax } = EAST_TO_WEST[0]!; + const json = new GreatCircle(tokyo, lax).Arc(100).json(); + expect(json.geometry.type).toBe('MultiLineString'); const coords = (json.geometry as any).coordinates; expect(coords.length).toBe(2); - - // Last point of first segment should be on +180 + const lastOfFirst = coords[0][coords[0].length - 1]; - expect(lastOfFirst[0]).toBe(180); - - // First point of second segment should be on -180 const firstOfSecond = coords[1][0]; + expect(lastOfFirst[0]).toBe(180); expect(firstOfSecond[0]).toBe(-180); - - // Both crossing points share the same interpolated latitude expect(lastOfFirst[1]).toBe(firstOfSecond[1]); }); test('should split Auckland-LA route at antimeridian with shared crossing point', () => { - const auckland = { x: 174.79, y: -36.85 }; - const la = { x: -118.41, y: 33.94 }; - - const gc = new GreatCircle(auckland, la); - const json = gc.Arc(100).json(); - + const { start: auckland, end: la } = EAST_TO_WEST[1]!; + const json = new GreatCircle(auckland, la).Arc(100).json(); + expect(json.geometry.type).toBe('MultiLineString'); const coords = (json.geometry as any).coordinates; expect(coords.length).toBe(2); - + const lastOfFirst = coords[0][coords[0].length - 1]; const firstOfSecond = coords[1][0]; expect(lastOfFirst[0]).toBe(180); @@ -220,16 +184,13 @@ describe('GreatCircle', () => { }); test('should split Shanghai-SFO route at antimeridian with shared crossing point', () => { - const shanghai = { x: 121.81, y: 31.14 }; - const sfo = { x: -122.38, y: 37.62 }; - - const gc = new GreatCircle(shanghai, sfo); - const json = gc.Arc(100).json(); - + const { start: shanghai, end: sfo } = EAST_TO_WEST[2]!; + const json = new GreatCircle(shanghai, sfo).Arc(100).json(); + expect(json.geometry.type).toBe('MultiLineString'); const coords = (json.geometry as any).coordinates; expect(coords.length).toBe(2); - + const lastOfFirst = coords[0][coords[0].length - 1]; const firstOfSecond = coords[1][0]; expect(lastOfFirst[0]).toBe(180); @@ -238,18 +199,12 @@ describe('GreatCircle', () => { }); test('should not have large longitude jumps within any segment', () => { - const tokyo = { x: 139.7798, y: 35.5494 }; - const lax = { x: -118.4085, y: 33.9416 }; - - const gc = new GreatCircle(tokyo, lax); - const json = gc.Arc(100).json(); - const coords = (json.geometry as any).coordinates; - + const { start: tokyo, end: lax } = EAST_TO_WEST[0]!; + const coords = (new GreatCircle(tokyo, lax).Arc(100).json().geometry as any).coordinates; + for (const segment of coords) { for (let i = 1; i < segment.length; i++) { - const lonDiff = Math.abs(segment[i][0] - segment[i - 1][0]); - // No segment should have an internal jump > 180 degrees - expect(lonDiff).toBeLessThan(180); + expect(Math.abs(segment[i][0] - segment[i - 1][0])).toBeLessThan(180); } } }); @@ -257,9 +212,8 @@ describe('GreatCircle', () => { describe('Error handling', () => { test('should handle NaN calculation errors', () => { - // This might trigger NaN in the calculation expect(() => { - new GreatCircle({ x: NaN, y: 0 }, endPoint); + new GreatCircle({ x: NaN, y: 0 }, TEN_EAST); }).toThrow(); }); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index 0d143e0..c37ec5a 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,55 +1,9 @@ -import { GreatCircle, CoordinatePoint } from '../src'; +import { GreatCircle } 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 → DC' }, - crossesAntimeridian: false - }, - { - start: { x: -122, y: 48 }, - end: { x: 0, y: 51 }, - properties: { name: 'Seattle → London' }, - crossesAntimeridian: false - }, - { - start: { x: -75.9375, y: 35.460669951495305 }, - end: { x: 146.25, y: -43.06888777416961 }, - 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: '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: 'Colombia/Peru border → Northern Territory, Australia' }, - crossesAntimeridian: true - }, - { - start: { x: -66.829833984375, y: -18.81271785640776 }, - end: { x: 118.795166015625, y: -20.797201434306984 }, - properties: { name: 'Challapata, Bolivia → Western Australia, Australia' }, - crossesAntimeridian: true - } -]; +import { INTEGRATION_ROUTES } from './fixtures/routes.js'; // Exact snapshots for non-crossing routes only. -// Splitting correctness for crossing routes (indices 2–5) is owned by antimeridian.test.ts. +// Splitting correctness for crossing routes is owned by antimeridian.test.ts. // Integration tests verify output format and property pass-through. const expectedArcs = [ { @@ -83,7 +37,7 @@ const expectedWkts = [ describe('Integration', () => { describe('Complex routes with dateline crossing', () => { - routes.forEach((route, idx) => { + INTEGRATION_ROUTES.forEach((route, idx) => { 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); @@ -106,7 +60,7 @@ describe('Integration', () => { }); describe('GeoJSON output validation', () => { - routes.forEach((route, idx) => { + INTEGRATION_ROUTES.forEach((route, idx) => { test(`Route ${idx} (${route.properties.name}) should produce valid GeoJSON`, () => { const gc = new GreatCircle(route.start, route.end, route.properties); const line = gc.Arc(3); @@ -126,7 +80,7 @@ describe('Integration', () => { }); describe('Southern hemisphere routes', () => { - const southernRoutes = routes.filter(route => + const southernRoutes = INTEGRATION_ROUTES.filter(route => route.start.y < 0 || route.end.y < 0 ); @@ -153,7 +107,7 @@ describe('Integration', () => { describe('Full workflow test', () => { test('should complete full workflow from coordinates to output formats', () => { - const testRoute = routes[0]!; // Seattle → DC + const testRoute = INTEGRATION_ROUTES[0]!; // Seattle → DC const gc = new GreatCircle(testRoute.start, testRoute.end, testRoute.properties); const line = gc.Arc(3); diff --git a/test/typescript.test.ts b/test/typescript.test.ts index ac6aeec..a6ae023 100644 --- a/test/typescript.test.ts +++ b/test/typescript.test.ts @@ -5,10 +5,11 @@ import { Arc, Coord, GreatCircle, CoordinatePoint, ArcOptions, GeoJSONFeature } from '../src'; import { expectTypeOf } from 'expect-type'; +import { SAN_FRANCISCO, NEW_YORK } from './fixtures/routes.js'; -// Test data with proper TypeScript typing -const sanFrancisco: CoordinatePoint = { x: -122.4194, y: 37.7749 }; -const newYork: CoordinatePoint = { x: -74.0059, y: 40.7128 }; +// Typed as CoordinatePoint — the type annotation is part of the type-safety test +const sanFrancisco: CoordinatePoint = SAN_FRANCISCO; +const newYork: CoordinatePoint = NEW_YORK; const testProperties = { name: 'TypeScript Test Route', id: 'ts-001', From 970148d3ee1ed0ffca299d85e9eecda455b17a06 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sat, 11 Apr 2026 11:13:40 -0700 Subject: [PATCH 10/15] chore(scripts): add dump-fixtures for visual route verification Generates a GeoJSON FeatureCollection of all test fixtures using the library itself (actual great circle arcs, not straight lines). Documents usage in DEVELOPING.md. --- DEVELOPING.md | 16 +++++- scripts/dump-fixtures.mjs | 103 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 scripts/dump-fixtures.mjs diff --git a/DEVELOPING.md b/DEVELOPING.md index 9616ce2..d6a7e91 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -12,11 +12,11 @@ npm test # Run TypeScript tests ## Project Structure -``` +```text src/ ├── index.ts # Main entry point ├── coord.ts # Coordinate class -├── arc.ts # Arc class +├── arc.ts # Arc class ├── great-circle.ts # Great circle calculations ├── line-string.ts # Internal geometry helper ├── utils.ts # Utility functions @@ -99,6 +99,18 @@ npm publish # prepublishOnly runs npm run build automatically import { GreatCircle } from 'arc'; ``` +## Visual Fixture Verification + +To inspect all test routes as great circle arcs on a map: + +```bash +npm run build # dist/ must exist +node scripts/dump-fixtures.mjs | pbcopy # macOS: copy to clipboard +``` + +Then, paste the geojson output into a visualization tool to visually verify routes, such as [geojson.io](https://geojson.io). +**Note:** route coordinates in the script are manually updated to keep in sync with `test/fixtures/routes.ts`. + ## Common Tasks ```bash diff --git a/scripts/dump-fixtures.mjs b/scripts/dump-fixtures.mjs new file mode 100644 index 0000000..a8995cc --- /dev/null +++ b/scripts/dump-fixtures.mjs @@ -0,0 +1,103 @@ +/** + * Dumps all test route fixtures as a GeoJSON FeatureCollection for visual + * verification. Paste the output into https://geojson.io to inspect routes. + * + * Usage: + * node scripts/dump-fixtures.mjs > fixtures.geojson + * node scripts/dump-fixtures.mjs | pbcopy # macOS: copy to clipboard + * + * Requires a built dist/ (run `npm run build` first). + * Route coordinates are duplicated from test/fixtures/routes.ts — plain JS + * cannot import TypeScript directly, so they are kept in sync manually. + */ + +import { GreatCircle } from '../dist/index.js'; + +// --------------------------------------------------------------------------- +// Route data — mirrors test/fixtures/routes.ts (kept in plain JS so no build +// step is needed beyond the library itself). +// --------------------------------------------------------------------------- + +const EAST_TO_WEST = [ + { name: 'Tokyo → LAX', start: [139.7798, 35.5494], end: [-118.4085, 33.9416] }, + { name: 'Auckland → LAX', start: [174.79, -36.85 ], end: [-118.41, 33.94 ] }, + { name: 'Shanghai → SFO', start: [121.81, 31.14 ], end: [-122.38, 37.62 ] }, +]; + +const WEST_TO_EAST = [ + { name: 'LAX → Tokyo', start: [-118.4085, 33.9416], end: [139.7798, 35.5494] }, + { name: 'LAX → Auckland', start: [-118.41, 33.94 ], end: [174.79, -36.85 ] }, + { name: 'SFO → Shanghai', start: [-122.38, 37.62 ], end: [121.81, 31.14 ] }, +]; + +const SOUTH_TO_SOUTH = [ + { name: 'Sydney → Buenos Aires', start: [151.21, -33.87], end: [-58.38, -34.60] }, + { name: 'Buenos Aires → Sydney', start: [-58.38, -34.60], end: [151.21, -33.87] }, +]; + +const HIGH_LATITUDE = [ + { name: 'Oslo → Anchorage', start: [ 10.74, 59.91], end: [-149.9, 61.22] }, + { name: 'London → Seattle', start: [ -0.12, 51.51], end: [-122.33, 47.61] }, +]; + +const NON_CROSSING = [ + { name: 'Seattle → DC', start: [-122.0, 48.0 ], end: [-77.0, 39.0 ] }, + { name: 'NYC → London', start: [ -74.0, 40.71], end: [ -0.13, 51.51] }, + { name: 'NYC → Paris', start: [ -74.0, 40.71], end: [ 2.35, 48.85] }, + { name: 'Lagos → Colombo', start: [ 3.4, 6.5 ], end: [ 79.9, 6.9 ] }, +]; + +const INTEGRATION = [ + { name: 'Seattle → DC', start: [ -122, 48 ], end: [ -77, 39 ] }, + { name: 'Seattle → London', start: [ -122, 48 ], end: [ 0, 51 ] }, + { name: 'Pamlico Sound → Tasmania', start: [ -75.9375, 35.460669951495305 ], end: [ 146.25, -43.06888777416961 ] }, + { name: 'Sea of Okhotsk → Southern Pacific', start: [ 145.546875, 48.45835188280866 ], end: [ -112.5, -37.71859032558814 ] }, + { name: 'Colombia/Peru border → Northern Territory', start: [ -74.564208984375, -0.17578097424708533], end: [ 137.779541015625, -22.75592068148639 ] }, + { name: 'Challapata, Bolivia → Western Australia', start: [ -66.829833984375,-18.81271785640776 ], end: [ 118.795166015625, -20.797201434306984 ] }, +]; + +// Group labels for styling in geojson.io +const GROUPS = [ + { routes: EAST_TO_WEST, group: 'crossing-E→W' }, + { routes: WEST_TO_EAST, group: 'crossing-W→E' }, + { routes: SOUTH_TO_SOUTH, group: 'crossing-south-south' }, + { routes: HIGH_LATITUDE, group: 'high-latitude' }, + { routes: NON_CROSSING, group: 'non-crossing' }, + { routes: INTEGRATION, group: 'integration' }, +]; + +// --------------------------------------------------------------------------- +// Generate features using the library — arcs reflect actual great circle paths +// --------------------------------------------------------------------------- + +const NPOINTS = 100; // resolution; higher = smoother curves + +const features = []; + +for (const { routes, group } of GROUPS) { + for (const { name, start, end } of routes) { + const gc = new GreatCircle({ x: start[0], y: start[1] }, { x: end[0], y: end[1] }, { name }); + const geojson = gc.Arc(NPOINTS).json(); + + // Arc geometry (actual great circle path produced by the library) + features.push({ + type: 'Feature', + properties: { name, group }, + geometry: geojson.geometry, + }); + + // Point markers for start and end + features.push({ + type: 'Feature', + properties: { name: `${name} (start)`, group, role: 'start' }, + geometry: { type: 'Point', coordinates: start }, + }); + features.push({ + type: 'Feature', + properties: { name: `${name} (end)`, group, role: 'end' }, + geometry: { type: 'Point', coordinates: end }, + }); + } +} + +console.log(JSON.stringify({ type: 'FeatureCollection', features }, null, 2)); From b154d4d912f46a818cb061c6904bc6cbe8970d14 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sat, 11 Apr 2026 11:14:48 -0700 Subject: [PATCH 11/15] chore(scripts): add benchmark comparing bisection vs linear interpolation Quantifies the performance cost of the analytical antimeridian bisection vs the prior GDAL linear interpolation approach. Runnable via `node scripts/benchmark.mjs` after `npm run build`. --- scripts/benchmark.mjs | 134 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 scripts/benchmark.mjs diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs new file mode 100644 index 0000000..ad7e02c --- /dev/null +++ b/scripts/benchmark.mjs @@ -0,0 +1,134 @@ +/** + * Benchmarks antimeridian bisection (current) vs linear interpolation (old GDAL heuristic). + * + * The old approach linearly interpolated the crossing latitude from the two already-computed + * adjacent sample points — zero additional interpolate() calls. + * + * The new approach runs 50 bisection iterations (2 interpolate() calls each = 100 calls) + * per antimeridian crossing to find the exact latitude. + * + * Usage: + * node scripts/benchmark.mjs + * + * Requires a built dist/: run `npm run build` first. + */ + +import { GreatCircle } from '../dist/index.js'; + +// --------------------------------------------------------------------------- +// Routes: one non-crossing (control) and three antimeridian crossings. +// All taken from test/fixtures/routes.ts. +// --------------------------------------------------------------------------- + +const ROUTES = { + 'Seattle → DC (non-crossing)': { start: { x: -122, y: 48 }, end: { x: -77, y: 39 } }, + 'Tokyo → LAX (1 crossing)': { start: { x: 139.7798, y: 35.5494 }, end: { x: -118.4085, y: 33.9416 } }, + 'Auckland → LAX (1 crossing)': { start: { x: 174.79, y: -36.85 }, end: { x: -118.41, y: 33.94 } }, + 'Shanghai → SFO (1 crossing)': { start: { x: 121.81, y: 31.14 }, end: { x: -122.38, y: 37.62 } }, +}; + +const NPOINTS_VALUES = [10, 100, 1000]; +const REPS = 2000; // repetitions per (route × npoints) cell + +// --------------------------------------------------------------------------- +// Baseline: linear interpolation (mirrors the old GDAL heuristic approach). +// When |Δlon| > 180, linearly interpolate the crossing latitude from the two +// adjacent already-computed sample points — no additional interpolate() calls. +// --------------------------------------------------------------------------- + +function arcLinear(gc, npoints) { + if (!npoints || npoints <= 2) return; + + const delta = 1.0 / (npoints - 1); + const points = []; + for (let i = 0; i < npoints; i++) { + points.push(gc.interpolate(delta * i)); + } + + const segments = []; + let current = []; + + for (let i = 0; i < points.length; i++) { + const pt = points[i]; + if (i === 0) { current.push(pt); continue; } + + const prev = points[i - 1]; + if (Math.abs(pt[0] - prev[0]) > 180) { + // Linear interpolation: estimate crossing lat from adjacent sampled points. + // t is how far along [prev→pt] the ±180 boundary lies, using lon values. + const t = (prev[0] > 0 ? 180 - prev[0] : -180 - prev[0]) / (pt[0] - prev[0]); + const crossingLat = prev[1] + t * (pt[1] - prev[1]); + const fromEast = prev[0] > 0; + current.push([fromEast ? 180 : -180, crossingLat]); + segments.push(current); + current = [[fromEast ? -180 : 180, crossingLat]]; + } + + current.push(pt); + } + if (current.length > 0) segments.push(current); + return segments; +} + +// --------------------------------------------------------------------------- +// Benchmark runner +// --------------------------------------------------------------------------- + +function bench(label, fn, reps) { + // Warm up V8 JIT + for (let i = 0; i < 50; i++) fn(); + + const t0 = performance.now(); + for (let i = 0; i < reps; i++) fn(); + const elapsed = performance.now() - t0; + + return { label, reps, totalMs: elapsed, usPerArc: (elapsed / reps) * 1000 }; +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +console.log(`Benchmark: bisection vs linear interpolation`); +console.log(`${REPS} reps per cell\n`); + +const header = ['Route', 'npoints', 'Method', 'µs/arc', 'overhead']; +console.log(header.join('\t')); +console.log(header.map(h => '-'.repeat(h.length)).join('\t')); + +for (const [routeName, { start, end }] of Object.entries(ROUTES)) { + const gc = new GreatCircle(start, end); + + for (const npoints of NPOINTS_VALUES) { + const bisection = bench( + `bisection n=${npoints}`, + () => gc.Arc(npoints), + REPS + ); + + const linear = bench( + `linear-interp n=${npoints}`, + () => arcLinear(gc, npoints), + REPS + ); + + const overhead = ((bisection.usPerArc - linear.usPerArc) / linear.usPerArc * 100).toFixed(1); + const overheadStr = overhead > 0 ? `+${overhead}%` : `${overhead}%`; + + console.log([ + routeName, + npoints, + 'bisection', + bisection.usPerArc.toFixed(2), + overheadStr, + ].join('\t')); + console.log([ + '', + '', + 'linear (baseline)', + linear.usPerArc.toFixed(2), + '', + ].join('\t')); + } + console.log(); +} From b4a8d1d07ef27adc091a754da9aefe95893627b9 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sun, 12 Apr 2026 21:20:02 -0700 Subject: [PATCH 12/15] refactor(great-circle): extract bisection constant and add inline comments - Extract 50-iteration bisection count into ANTIMERIDIAN_BISECTION_ITERATIONS constant - Add inline comments explaining each phase of the Arc() algorithm - Clean up comments across src/ for clarity and consistency - Add Thomas Hervey to contributors in package.json --- package.json | 3 ++- src/arc.ts | 19 +++++++++---------- src/great-circle.ts | 41 ++++++++++++++++++++++------------------- src/index.ts | 8 +++++++- src/utils.ts | 6 +++--- 5 files changed, 43 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index fe247c8..6ff9827 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ ], "contributors": [ "Dane Springmeyer ", - "John Gravois " + "John Gravois ", + "Thomas Hervey " ], "repository": { "type": "git", diff --git a/src/arc.ts b/src/arc.ts index 4b85a75..c1a5b45 100644 --- a/src/arc.ts +++ b/src/arc.ts @@ -4,9 +4,9 @@ import type { Position } from 'geojson'; /** * Arc class representing the result of great circle calculations - * + * * @param properties - Optional properties object - * + * * @example * ```typescript * const arc = new Arc({ x: 45.123456789, y: 50.987654321 }); @@ -23,14 +23,14 @@ export class Arc { /** * Convert to GeoJSON Feature - * + * * @returns GeoJSON Feature with LineString or MultiLineString geometry - * + * * @example * ```typescript * const gc = new GreatCircle({x: -122, y: 48}, {x: -77, y: 39}); * const arc = gc.Arc(3); - * console.log(arc.json()); + * console.log(arc.json()); * // { type: 'Feature', geometry: { type: 'LineString', coordinates: [[-122, 48], [-99.5, 43.5], [-77, 39]] }, properties: {} } * ``` */ @@ -39,8 +39,7 @@ export class Arc { if (this.geometries.length === 0) { return { type: 'Feature', - // NOTE: coordinates: null is non-standard GeoJSON (RFC 7946 specifies empty array []) - // but maintained for backward compatibility with original arc.js behavior + // NOTE: coordinates: null is non-standard GeoJSON (RFC 7946 specifies empty array []) but maintained for backward compatibility with original arc.js behavior. geometry: { type: 'LineString', coordinates: null as any }, properties: this.properties }; @@ -78,9 +77,9 @@ export class Arc { /** * Convert to WKT (Well Known Text) format - * + * * @returns WKT string representation - * + * * @example * ```typescript * const arc = new Arc({ name: 'test-arc' }); @@ -93,7 +92,7 @@ export class Arc { } let wktParts: string[] = []; - + for (const geometry of this.geometries) { if (!geometry || geometry.coords.length === 0) { wktParts.push('LINESTRING EMPTY'); diff --git a/src/great-circle.ts b/src/great-circle.ts index 60002ec..81ef839 100644 --- a/src/great-circle.ts +++ b/src/great-circle.ts @@ -4,15 +4,20 @@ import { Arc } from './arc.js'; import { _LineString } from './line-string.js'; import { roundCoords, R2D } from './utils.js'; +// Number of bisection iterations used to locate the antimeridian crossing. +// More iterations = higher precision but more interpolate() calls. +// 50 iterations yields sub-degree precision, which is more than sufficient for most web mapping applications (i.e., not survey grade). +const ANTIMERIDIAN_BISECTION_ITERATIONS = 50; + /** * Great Circle calculation class * http://en.wikipedia.org/wiki/Great-circle_distance - * + * * @param start - Start point * @param end - End point * @param properties - Optional properties object - * + * * @example * ```typescript * const greatCircle = new GreatCircle({ x: 45.123456789, y: 50.987654321 }, { x: 46.123456789, y: 51.987654321 }); @@ -32,7 +37,7 @@ export class GreatCircle { if (!end || end.x === undefined || end.y === undefined) { throw new Error("GreatCircle constructor expects two args: start and end objects with x and y properties"); } - + this.start = new Coord(start.x, start.y); this.end = new Coord(end.x, end.y); this.properties = properties || {}; @@ -55,10 +60,10 @@ export class GreatCircle { /** * Interpolate along the great circle * http://williams.best.vwh.net/avform.htm#Intermediate - * + * * @param f - Interpolation factor * @returns Interpolated point - * + * * @example * ```typescript * const greatCircle = new GreatCircle({ x: 45.123456789, y: 50.987654321 }, { x: 46.123456789, y: 51.987654321 }); @@ -78,11 +83,11 @@ export class GreatCircle { /** * Generate points along the great circle - * + * * @param npoints - Number of points to generate * @param options - Optional options object * @returns Arc object - * + * * @example * ```typescript * const greatCircle = new GreatCircle({ x: 45.123456789, y: 50.987654321 }, { x: 46.123456789, y: 51.987654321 }); @@ -91,11 +96,7 @@ export class GreatCircle { */ Arc(npoints: number = 100, _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. + // 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) { const arc = new Arc(this.properties); const line = new _LineString(); @@ -105,19 +106,16 @@ export class GreatCircle { return arc; } - // 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. + // 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. + // Sample npoints evenly spaced positions along the great circle arc. 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)); } - // 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. + // Walk the sampled points, splitting into segments wherever the arc crosses the antimeridian. const segments: [number, number][][] = []; let current: [number, number][] = []; @@ -131,14 +129,17 @@ export class GreatCircle { const prev = first_pass[i - 1]!; + // A longitude jump > 180° between adjacent samples indicates an antimeridian crossing. if (Math.abs(pt[0] - prev[0]) > 180) { + // Bisect to find the interpolation fraction f* at which the arc crosses ±180°. let lo = delta * (i - 1); let hi = delta * i; - for (let iter = 0; iter < 50; iter++) { + for (let iter = 0; iter < ANTIMERIDIAN_BISECTION_ITERATIONS; iter++) { const mid = (lo + hi) / 2; const [midLon] = this.interpolate(mid); const [loLon] = this.interpolate(lo); + // If mid and lo are on the same side of ±180°, the crossing is in [mid, hi]. if (Math.abs(midLon - loLon) < 180) { lo = mid; } else { @@ -146,6 +147,7 @@ export class GreatCircle { } } + // Compute the latitude at the crossing point and close/open segments at ±180°. const [, crossingLat] = this.interpolate((lo + hi) / 2); const fromEast = prev[0] > 0; @@ -161,6 +163,7 @@ export class GreatCircle { segments.push(current); } + // Build one LineString per segment and collect them into an Arc. const arc = new Arc(this.properties); for (const seg of segments) { const line = new _LineString(); diff --git a/src/index.ts b/src/index.ts index 3280094..9ff5ec5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,4 +5,10 @@ export { GreatCircle } from './great-circle.js'; export { roundCoords, D2R, R2D } from './utils.js'; // Export types -export type { CoordinatePoint, ArcOptions, GeoJSONFeature, LineString, MultiLineString } from './types.js'; +export type { + ArcOptions, + CoordinatePoint, + GeoJSONFeature, + LineString, + MultiLineString +} from './types.js'; diff --git a/src/utils.ts b/src/utils.ts index 3d08192..1759719 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,10 +2,10 @@ import type { Position } from './types.js'; /** * Round coordinate decimal values to 6 places for precision - * + * * @param coords - A coordinate position (longitude, latitude, optional elevation) * @returns Rounded coordinate position - * + * * @example * ```typescript * const coords = [45.123456789, 50.987654321]; @@ -22,7 +22,7 @@ export function roundCoords(coords: Position): Position { for (let i = 0; i < coords.length; i++) { const coord = coords[i]; if (coord !== undefined) { - // https://stackoverflow.com/questions/11832914/how-to-round-to-at-most-2-decimal-places-if-necessary + // NOTE: This logic follows https://stackoverflow.com/questions/11832914/how-to-round-to-at-most-2-decimal-places-if-necessary rounded[i] = Math.round( (coord + Number.EPSILON) * MULTIPLIER ) / MULTIPLIER; From 86e53765c45440c7df6671f8cb3bf29420c9df72 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sun, 12 Apr 2026 21:41:24 -0700 Subject: [PATCH 13/15] chore: bump version to 1.0.0 and update CHANGELOG --- CHANGELOG.md | 21 ++++++++++++++++++--- package.json | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb14ca..2b8b5c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,11 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased][unreleased] -## [1.0.0] - 2026-03-29 +## [1.0.0] - 2026-04-12 ### Breaking change -- arc.js is now a [pure](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) ESM package. +- arc.js is now a [pure](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) ESM package (from @jgravois). If you need to `require()` arc.js as CJS (CommonJS), or have a runtime older than Node.js 18, please use `0.1.4`. @@ -27,7 +27,22 @@ const gc = new GreatCircle(/* */); ### Fixed -- antimeridian splitting in GreatCircle.Arc (From @copilot) +- Antimeridian splitting in GreatCircle.Arc (from @thomas-hervey) + +### Changed + +- `GreatCircle.Arc()` now defaults to `npoints = 100` — calling `gc.Arc()` with no arguments produces a smooth 100-point arc instead of a 2-point stub +- Antimeridian splitting now uses analytical bisection (binary search on `interpolate()`) instead of the GDAL-ported linear heuristic. This approach is more accurate, especially at high latitudes and low `npoints` values +- `ArcOptions.offset` is now a no-op (kept for backwards compatibility); antimeridian handling is fully automatic + +### Removed + +- GDAL license file (`GDAL-LICENSE.md`). No GDAL-derived code remains in the codebase + +### Added + +- `scripts/benchmark.mjs` benchmarks bisection vs. prior linear approach across npoints and route types +- `scripts/dump-fixtures.mjs` exports all test routes as GeoJSON for use in visual verification (such as [https://geojson.io](https://geojson.io) or the index.html demo page) ## [0.2.0] - 2025-09-22 ### Breaking diff --git a/package.json b/package.json index 6ff9827..2880bda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arc", - "version": "0.2.0", + "version": "1.0.0", "description": "draw great circle arcs", "url": "https://github.com/springmeyer/arc.js", "keywords": [ From f2a36235e11897d7a211c4cb20429e4fdd578361 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Mon, 13 Apr 2026 11:07:19 -0700 Subject: [PATCH 14/15] docs(readme): show npoints is optional, defaults to 100 --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3aca534..ecce375 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ npm install arc ```js import { GreatCircle } from 'arc'; const gc = new GreatCircle({x: -122, y: 48}, {x: -77, y: 39}); -const line = gc.Arc(100); +const line = gc.Arc(); // npoints is optional, defaults to 100 console.log(line.json()); // GeoJSON output ``` @@ -64,11 +64,12 @@ const gc = new GreatCircle(start, end, { name: 'Seattle to DC' }); #### 3. Generate the arc ```js -const line = gc.Arc(100); +const line = gc.Arc(); // defaults to 100 points +const line = gc.Arc(500); // or specify a custom value ``` **Parameters:** -- `npoints` (number): Number of intermediate points (higher = more accurate) +- `npoints` (number, optional): Number of intermediate points (higher = more precise, default: 100) ### TypeScript Support @@ -86,7 +87,7 @@ const end: CoordinatePoint = { x: -77, y: 39 }; const properties: RouteProperties = { name: 'Seattle to DC', color: 'blue' }; const gc = new GreatCircle(start, end, properties); -const line = gc.Arc(100); +const line = gc.Arc(); // npoints is optional, defaults to 100 // Fully typed return values const geojson = line.json(); // GeoJSONFeature From c5ea41acdcec4aef5b753b59459bb976ce70733d Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Mon, 13 Apr 2026 11:38:02 -0700 Subject: [PATCH 15/15] fix: address jgravois review comments on PR #79 - Remove redundant `!npoints` check now that default is 100 - Add src/ to package.json files array and update DEVELOPING.md - Fix Browser ESM example: arc.GreatCircle -> GreatCircle, Arc(100) -> Arc() - Fix CHANGELOG.md indentation in package.json files array --- DEVELOPING.md | 1 + README.md | 4 ++-- package.json | 3 ++- src/great-circle.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/DEVELOPING.md b/DEVELOPING.md index d6a7e91..ab03621 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -75,6 +75,7 @@ npm publish # prepublishOnly runs npm run build automatically ### What Gets Published - `dist/` folder (compiled ESM JS + TypeScript definitions) +- `src/` folder (TypeScript source files) - `README.md`, `LICENSE.md`, `CHANGELOG.md` ## TypeScript Development diff --git a/README.md b/README.md index ecce375..cafc309 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ const line = gc.Arc(100); ```html ``` diff --git a/package.json b/package.json index 2880bda..0b7a43a 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,10 @@ }, "files": [ "dist/", + "src/", "README.md", "LICENSE.md", -"CHANGELOG.md" + "CHANGELOG.md" ], "engines": { "node": ">=18" diff --git a/src/great-circle.ts b/src/great-circle.ts index 81ef839..a00a4b0 100644 --- a/src/great-circle.ts +++ b/src/great-circle.ts @@ -97,7 +97,7 @@ export class GreatCircle { Arc(npoints: number = 100, _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) { + if (npoints <= 2) { const arc = new Arc(this.properties); const line = new _LineString(); arc.geometries.push(line);