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/DEVELOPING.md b/DEVELOPING.md index 3841fb8..ab03621 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -6,26 +6,24 @@ 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 -``` +```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 └── 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,13 @@ 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) +- `src/` folder (TypeScript source files) - `README.md`, `LICENSE.md`, `CHANGELOG.md` ## TypeScript Development @@ -94,32 +83,34 @@ 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) - +## 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 ``` -All formats are tested in `test/build-output.test.js`. +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 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..cafc309 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 @@ -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 ``` @@ -40,8 +40,8 @@ const line = gc.Arc(100); ```html ``` @@ -64,12 +64,12 @@ 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(); // defaults to 100 points +const line = gc.Arc(500); // or specify a custom value ``` **Parameters:** -- `npoints` (number): Number of intermediate points (higher = more accurate) -- `options.offset` (number): Dateline crossing threshold in degrees (default: 10) +- `npoints` (number, optional): Number of intermediate points (higher = more precise, default: 100) ### TypeScript Support @@ -87,8 +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 options: ArcOptions = { offset: 10 }; -const line = gc.Arc(100, options); +const line = gc.Arc(); // npoints is optional, defaults to 100 // Fully typed return values const geojson = line.json(); // GeoJSONFeature @@ -144,7 +143,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 @@ -157,7 +156,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/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/package.json b/package.json index 3120ade..0b7a43a 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": [ @@ -14,7 +14,8 @@ ], "contributors": [ "Dane Springmeyer ", - "John Gravois " + "John Gravois ", + "Thomas Hervey " ], "repository": { "type": "git", @@ -28,9 +29,9 @@ }, "files": [ "dist/", + "src/", "README.md", "LICENSE.md", - "GDAL-LICENSE.md", "CHANGELOG.md" ], "engines": { 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(); +} 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)); 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 a4ac11f..a00a4b0 100644 --- a/src/great-circle.ts +++ b/src/great-circle.ts @@ -4,24 +4,20 @@ 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 - */ +// 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 }); @@ -41,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 || {}; @@ -64,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 }); @@ -87,144 +83,93 @@ 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 }); * console.log(greatCircle.Arc(10)); // Arc { geometries: [ [Array] ] } * ``` */ - Arc(npoints?: number, options?: ArcOptions): Arc { - let first_pass: [number, number][] = []; - - 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); - } + 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 <= 2) { + 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. + + // 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)); } - 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; - } + // Walk the sampled points, splitting into segments wherever the arc crosses the antimeridian. + 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]!; + + // 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 < 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 { + hi = mid; } - poNewLS.push([dfX0, first_pass[k]?.[1] ?? 0]); - } else { - poNewLS.push([first_pass[k]?.[0] ?? 0, first_pass[k]?.[1] ?? 0]); } + + // 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; + + 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); } + // Build one LineString per segment and collect them into an Arc. 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/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/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; } 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; diff --git a/test/antimeridian.test.ts b/test/antimeridian.test.ts new file mode 100644 index 0000000..b806de5 --- /dev/null +++ b/test/antimeridian.test.ts @@ -0,0 +1,157 @@ +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'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +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]; + + expect(seg0).toBeDefined(); + expect(seg1).toBeDefined(); + if (!seg0 || !seg1) return; + + const lastOfFirst = seg0[seg0.length - 1]; + const firstOfSecond = seg1[0]; + + 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); + + // Latitudes must match — no gap + 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}`, () => { + 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('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('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('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`, () => { + 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', () => { + 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 cee7508..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); - const arc = gc.Arc(10, { offset: 5 }); - + // 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 with high offset', () => { - 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 }); - + test('should handle routes near dateline', () => { + 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, { offset: 10 }).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, { offset: 10 }).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, { offset: 10 }).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, { offset: 10 }).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 dda3aad..c37ec5a 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,48 +1,13 @@ -import { GreatCircle, CoordinatePoint } from '../src'; - -// Complex real-world routes for integration testing -interface TestRoute { - start: CoordinatePoint; - end: CoordinatePoint; - properties: { name: string }; -} - -const routes: TestRoute[] = [ - { - start: { x: -122, y: 48 }, - end: { x: -77, y: 39 }, - properties: { name: 'Seattle to DC' } - }, - { - start: { x: -122, y: 48 }, - end: { x: 0, y: 51 }, - properties: { name: 'Seattle to London' } - }, - { - start: { x: -75.9375, y: 35.460669951495305 }, - end: { x: 146.25, y: -43.06888777416961 }, - properties: { name: 'crosses dateline 1' } - }, - { - start: { x: 145.54687500000003, y: 48.45835188280866 }, - end: { x: -112.5, y: -37.71859032558814 }, - properties: { name: 'crosses dateline 2' } - }, - { - start: { x: -74.564208984375, y: -0.17578097424708533 }, - end: { x: 137.779541015625, y: -22.75592068148639 }, - properties: { name: 'south 1' } - }, - { - start: { x: -66.829833984375, y: -18.81271785640776 }, - end: { x: 118.795166015625, y: -20.797201434306984 }, - properties: { name: 'south 2' } - } -]; +import { GreatCircle } from '../src'; +import type { MultiLineString, LineString } from 'geojson'; +import { INTEGRATION_ROUTES } from './fixtures/routes.js'; +// Exact snapshots for non-crossing routes only. +// Splitting correctness for crossing routes 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 +18,7 @@ const expectedArcs = [ }] }, { - "properties": { "name": "Seattle to London" }, + "properties": { "name": "Seattle → London" }, "geometries": [{ "coords": [ [-122, 48], @@ -62,93 +27,50 @@ 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', () => { 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); - - // 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('; '); + } }); }); }); 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); 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 +79,25 @@ describe('Integration', () => { }); }); - describe('Dateline crossing behavior', () => { - const datelineCrossingRoutes = routes.filter(route => - route.properties.name.includes('crosses dateline') + describe('Southern hemisphere routes', () => { + const southernRoutes = INTEGRATION_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 +107,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 = INTEGRATION_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); diff --git a/test/typescript.test.ts b/test/typescript.test.ts index dd3f0a2..a6ae023 100644 --- a/test/typescript.test.ts +++ b/test/typescript.test.ts @@ -5,12 +5,13 @@ 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 }; -const testProperties = { - name: 'TypeScript Test Route', +// 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', metadata: { framework: 'Jest', language: 'TypeScript' } }; @@ -19,17 +20,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 +40,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 +74,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 +89,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 +105,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 +118,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 +136,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 +149,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();