From 807167d85021686bd7d384ede1b039ad3795251d Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 9 Apr 2026 15:23:57 -0700 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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;