diff --git a/DEVELOPING.md b/DEVELOPING.md
index 3841fb8..d6a7e91 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,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,32 +82,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/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 @@
};
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 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/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 72442bb..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,24 +83,20 @@ 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 {
+ 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/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
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 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 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 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();