diff --git a/GDAL-LICENSE.md b/GDAL-LICENSE.md deleted file mode 100644 index 1381730..0000000 --- a/GDAL-LICENSE.md +++ /dev/null @@ -1,57 +0,0 @@ -# GDAL License Attribution - -This project includes code ported from the GDAL (Geospatial Data Abstraction Library) project, specifically from the OGR library. - -## GDAL License - -GDAL is licensed under the MIT/X11 license. The following license text applies to the GDAL code portions used in this project: - -``` -Copyright (c) 2000, Frank Warmerdam -Copyright (c) 2008-2014, Even Rouault -Copyright (c) 2015, Faza Mahamood -Copyright (c) 2016, Ari Jolma -Copyright (c) 2017, Ari Jolma -Copyright (c) 2018, Ari Jolma -Copyright (c) 2019, Ari Jolma -Copyright (c) 2020, Ari Jolma -Copyright (c) 2021, Ari Jolma -Copyright (c) 2022, Ari Jolma -Copyright (c) 2023, Ari Jolma -Copyright (c) 2024, Ari Jolma - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL -THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. -``` - -## Ported Code - -The following files contain code ported from GDAL: - -- `src/great-circle.ts` - Dateline handling logic ported from `gdal/ogr/ogrgeometryfactory.cpp` - -## Original Source - -- **GDAL Repository**: https://github.com/OSGeo/gdal -- **Specific File**: `gdal/ogr/ogrgeometryfactory.cpp` -- **Commit Reference**: 7bfb9c452a59aac958bff0c8386b891edf8154ca -- **GDAL Website**: https://gdal.org/ - -## Modifications - -The ported code has been adapted from C++ to TypeScript and integrated into the arc.js library's great circle calculation functionality. The core dateline handling algorithm remains functionally equivalent to the original GDAL implementation. diff --git a/README.md b/README.md index 7840aee..00d201e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Calculate great circle routes as lines in GeoJSON or WKT format. - Works in Node.js and browsers - Generates GeoJSON and WKT output formats - Handles dateline crossing automatically -- Based on [Ed Williams' Aviation Formulary](https://edwilliams.org/avform.htm#Intermediate) algorithms and the GDAL source code +- Based on [Ed Williams' Aviation Formulary](https://edwilliams.org/avform.htm#Intermediate) algorithms ## Installation @@ -157,7 +157,3 @@ arc.js powers the [`greatCircle`](https://turfjs.org/docs/api/greatCircle) funct ## License This project is licensed under the BSD license. See [LICENSE.md](LICENSE) for details. - -### Third-Party Licenses - -This project includes code ported from GDAL (Geospatial Data Abstraction Library), which is licensed under the MIT/X11 license. See [GDAL-LICENSE.md](GDAL-LICENSE.md) for the full GDAL license text and attribution details. diff --git a/package.json b/package.json index 3120ade..fe247c8 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,7 @@ "dist/", "README.md", "LICENSE.md", - "GDAL-LICENSE.md", - "CHANGELOG.md" +"CHANGELOG.md" ], "engines": { "node": ">=18" diff --git a/src/great-circle.ts b/src/great-circle.ts index a4ac11f..72442bb 100644 --- a/src/great-circle.ts +++ b/src/great-circle.ts @@ -4,15 +4,6 @@ import { Arc } from './arc.js'; import { _LineString } from './line-string.js'; import { roundCoords, R2D } from './utils.js'; -/* - * Portions of this file contain code ported from GDAL (Geospatial Data Abstraction Library) - * - * GDAL is licensed under the MIT/X11 license. - * See GDAL-LICENSE.md for the full license text. - * - * Original source: gdal/ogr/ogrgeometryfactory.cpp - * Repository: https://github.com/OSGeo/gdal - */ /** * Great Circle calculation class @@ -98,133 +89,84 @@ export class GreatCircle { * console.log(greatCircle.Arc(10)); // Arc { geometries: [ [Array] ] } * ``` */ - Arc(npoints?: number, options?: ArcOptions): Arc { - let first_pass: [number, number][] = []; - + Arc(npoints?: number, _options?: ArcOptions): Arc { + // NOTE: With npoints ≤ 2, no antimeridian splitting is performed. + // A 2-point antimeridian route returns a single LineString spanning ±180°. + // Renderers that support coordinate wrapping (e.g. MapLibre GL JS) handle this + // correctly, whereas splitting would produce two disconnected straight-line stubs + // with no great-circle curvature — arguably worse behavior. This is a known + // limitation; open for maintainer discussion if a MultiLineString split is preferred. if (!npoints || npoints <= 2) { - first_pass.push([this.start.lon, this.start.lat]); - first_pass.push([this.end.lon, this.end.lat]); - } else { - const delta = 1.0 / (npoints - 1); - for (let i = 0; i < npoints; ++i) { - const step = delta * i; - const pair = this.interpolate(step); - first_pass.push(pair); - } + const arc = new Arc(this.properties); + const line = new _LineString(); + arc.geometries.push(line); + line.move_to(roundCoords([this.start.lon, this.start.lat])); + line.move_to(roundCoords([this.end.lon, this.end.lat])); + return arc; } - /* partial port of dateline handling from: - gdal/ogr/ogrgeometryfactory.cpp - - TODO - does not handle all wrapping scenarios yet - */ - let bHasBigDiff = false; - let dfMaxSmallDiffLong = 0; - // from http://www.gdal.org/ogr2ogr.html - // -datelineoffset: - // (starting with GDAL 1.10) offset from dateline in degrees (default long. = +/- 10deg, geometries within 170deg to -170deg will be splited) - const dfDateLineOffset = options?.offset ?? 10; - const dfLeftBorderX = 180 - dfDateLineOffset; - const dfRightBorderX = -180 + dfDateLineOffset; - const dfDiffSpace = 360 - dfDateLineOffset; - - // https://github.com/OSGeo/gdal/blob/7bfb9c452a59aac958bff0c8386b891edf8154ca/gdal/ogr/ogrgeometryfactory.cpp#L2342 - for (let j = 1; j < first_pass.length; ++j) { - const dfPrevX = first_pass[j-1]?.[0] ?? 0; - const dfX = first_pass[j]?.[0] ?? 0; - const dfDiffLong = Math.abs(dfX - dfPrevX); - if (dfDiffLong > dfDiffSpace && - ((dfX > dfLeftBorderX && dfPrevX < dfRightBorderX) || (dfPrevX > dfLeftBorderX && dfX < dfRightBorderX))) { - bHasBigDiff = true; - } else if (dfDiffLong > dfMaxSmallDiffLong) { - dfMaxSmallDiffLong = dfDiffLong; - } + // NOTE: options.offset was previously used as dfDateLineOffset in the GDAL-ported + // heuristic. It is kept in ArcOptions for backwards compatibility but is a no-op here. + + const delta = 1.0 / (npoints - 1); + const first_pass: [number, number][] = []; + for (let i = 0; i < npoints; ++i) { + first_pass.push(this.interpolate(delta * i)); } - const poMulti: [number, number][][] = []; - if (bHasBigDiff && dfMaxSmallDiffLong < dfDateLineOffset) { - let poNewLS: [number, number][] = []; - poMulti.push(poNewLS); - for (let k = 0; k < first_pass.length; ++k) { - const dfX0 = parseFloat((first_pass[k]?.[0] ?? 0).toString()); - if (k > 0 && Math.abs(dfX0 - (first_pass[k-1]?.[0] ?? 0)) > dfDiffSpace) { - let dfX1 = parseFloat((first_pass[k-1]?.[0] ?? 0).toString()); - let dfY1 = parseFloat((first_pass[k-1]?.[1] ?? 0).toString()); - let dfX2 = parseFloat((first_pass[k]?.[0] ?? 0).toString()); - let dfY2 = parseFloat((first_pass[k]?.[1] ?? 0).toString()); - if (dfX1 > -180 && dfX1 < dfRightBorderX && dfX2 === 180 && - k+1 < first_pass.length && - (first_pass[k-1]?.[0] ?? 0) > -180 && (first_pass[k-1]?.[0] ?? 0) < dfRightBorderX) - { - poNewLS.push([-180, first_pass[k]?.[1] ?? 0]); - k++; - poNewLS.push([first_pass[k]?.[0] ?? 0, first_pass[k]?.[1] ?? 0]); - continue; - } else if (dfX1 > dfLeftBorderX && dfX1 < 180 && dfX2 === -180 && - k+1 < first_pass.length && - (first_pass[k-1]?.[0] ?? 0) > dfLeftBorderX && (first_pass[k-1]?.[0] ?? 0) < 180) - { - poNewLS.push([180, first_pass[k]?.[1] ?? 0]); - k++; - poNewLS.push([first_pass[k]?.[0] ?? 0, first_pass[k]?.[1] ?? 0]); - continue; - } + // Analytical antimeridian splitting via bisection. + // For each consecutive pair of points where |Δlon| > 180 (opposite sides of ±180°), + // binary-search for the exact crossing fraction f* using interpolate(), then insert + // [±180, lat*] boundary points and start a new segment. 50 iterations → sub-nanodegree precision. + const segments: [number, number][][] = []; + let current: [number, number][] = []; - if (dfX1 < dfRightBorderX && dfX2 > dfLeftBorderX) - { - // swap dfX1, dfX2 - const tmpX = dfX1; - dfX1 = dfX2; - dfX2 = tmpX; - // swap dfY1, dfY2 - const tmpY = dfY1; - dfY1 = dfY2; - dfY2 = tmpY; - } - if (dfX1 > dfLeftBorderX && dfX2 < dfRightBorderX) { - dfX2 += 360; - } + for (let i = 0; i < first_pass.length; i++) { + const pt = first_pass[i]!; - if (dfX1 <= 180 && dfX2 >= 180 && dfX1 < dfX2) - { - const dfRatio = (180 - dfX1) / (dfX2 - dfX1); - const dfY = dfRatio * dfY2 + (1 - dfRatio) * dfY1; - poNewLS.push([(first_pass[k-1]?.[0] ?? 0) > dfLeftBorderX ? 180 : -180, dfY]); - poNewLS = []; - poNewLS.push([(first_pass[k-1]?.[0] ?? 0) > dfLeftBorderX ? -180 : 180, dfY]); - poMulti.push(poNewLS); - } - else - { - poNewLS = []; - poMulti.push(poNewLS); + if (i === 0) { + current.push(pt); + continue; + } + + const prev = first_pass[i - 1]!; + + if (Math.abs(pt[0] - prev[0]) > 180) { + let lo = delta * (i - 1); + let hi = delta * i; + + for (let iter = 0; iter < 50; iter++) { + const mid = (lo + hi) / 2; + const [midLon] = this.interpolate(mid); + const [loLon] = this.interpolate(lo); + if (Math.abs(midLon - loLon) < 180) { + lo = mid; + } else { + hi = mid; } - poNewLS.push([dfX0, first_pass[k]?.[1] ?? 0]); - } else { - poNewLS.push([first_pass[k]?.[0] ?? 0, first_pass[k]?.[1] ?? 0]); } + + const [, crossingLat] = this.interpolate((lo + hi) / 2); + const fromEast = prev[0] > 0; + + current.push([fromEast ? 180 : -180, crossingLat]); + segments.push(current); + current = [[fromEast ? -180 : 180, crossingLat]]; } - } else { - // add normally - const poNewLS0: [number, number][] = []; - poMulti.push(poNewLS0); - for (let l = 0; l < first_pass.length; ++l) { - poNewLS0.push([first_pass[l]?.[0] ?? 0, first_pass[l]?.[1] ?? 0]); - } + + current.push(pt); + } + + if (current.length > 0) { + segments.push(current); } const arc = new Arc(this.properties); - for (let m = 0; m < poMulti.length; ++m) { + for (const seg of segments) { const line = new _LineString(); arc.geometries.push(line); - const points = poMulti[m]; - if (points) { - for (let j0 = 0; j0 < points.length; ++j0) { - const point = points[j0]; - if (point) { - line.move_to(roundCoords([point[0], point[1]])); - } - } + for (const pt of seg) { + line.move_to(roundCoords([pt[0], pt[1]])); } } return arc; diff --git a/test/antimeridian.test.ts b/test/antimeridian.test.ts new file mode 100644 index 0000000..61f5b9e --- /dev/null +++ b/test/antimeridian.test.ts @@ -0,0 +1,153 @@ +import { GreatCircle } from '../src'; +import type { MultiLineString, LineString } from 'geojson'; + +// 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 } }, +]; + +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); +} + +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('high-latitude routes', () => { + for (const { name, start, end } of HIGH_LATITUDE) { + test(`${name} produces valid GeoJSON with no large longitude jumps`, () => { + const result = new GreatCircle(start, end).Arc(100).json(); + expect(['LineString', 'MultiLineString']).toContain(result.geometry.type); + + const allCoords: number[][] = result.geometry.type === 'MultiLineString' + ? (result.geometry as MultiLineString).coordinates.flat() + : (result.geometry as LineString).coordinates; + + for (let i = 1; i < allCoords.length; i++) { + const prev = allCoords[i - 1]; + const curr = allCoords[i]; + if (!prev || !curr) continue; + expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(180); + } + }); + } +}); + +describe('non-crossing routes are unaffected', () => { + test('Seattle → DC returns a LineString with no longitude jumps', () => { + const result = new GreatCircle({ x: -122, y: 48 }, { x: -77, y: 39 }).Arc(100).json(); + expect(result.geometry.type).toBe('LineString'); + + const coords = (result.geometry as LineString).coordinates; + for (let i = 1; i < coords.length; i++) { + const prev = coords[i - 1]; + const curr = coords[i]; + if (!prev || !curr) continue; + expect(Math.abs((curr[0] ?? 0) - (prev[0] ?? 0))).toBeLessThan(20); + } + }); +}); diff --git a/test/integration.test.ts b/test/integration.test.ts index dda3aad..0d143e0 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,48 +1,59 @@ import { GreatCircle, CoordinatePoint } from '../src'; +import type { MultiLineString, LineString } from 'geojson'; // Complex real-world routes for integration testing interface TestRoute { start: CoordinatePoint; end: CoordinatePoint; properties: { name: string }; + crossesAntimeridian: boolean; } const routes: TestRoute[] = [ { start: { x: -122, y: 48 }, end: { x: -77, y: 39 }, - properties: { name: 'Seattle to DC' } + properties: { name: 'Seattle → DC' }, + crossesAntimeridian: false }, { start: { x: -122, y: 48 }, end: { x: 0, y: 51 }, - properties: { name: 'Seattle to London' } + properties: { name: 'Seattle → London' }, + crossesAntimeridian: false }, { start: { x: -75.9375, y: 35.460669951495305 }, end: { x: 146.25, y: -43.06888777416961 }, - properties: { name: 'crosses dateline 1' } + properties: { name: 'Pamlico Sound, NC, USA → Tasmania, Australia' }, + crossesAntimeridian: true }, { start: { x: 145.54687500000003, y: 48.45835188280866 }, end: { x: -112.5, y: -37.71859032558814 }, - properties: { name: 'crosses dateline 2' } + properties: { name: 'Sea of Okhotsk, Russia → Southern Pacific Ocean' }, + crossesAntimeridian: true }, { start: { x: -74.564208984375, y: -0.17578097424708533 }, end: { x: 137.779541015625, y: -22.75592068148639 }, - properties: { name: 'south 1' } + properties: { name: 'Colombia/Peru border → Northern Territory, Australia' }, + crossesAntimeridian: true }, { start: { x: -66.829833984375, y: -18.81271785640776 }, end: { x: 118.795166015625, y: -20.797201434306984 }, - properties: { name: 'south 2' } + properties: { name: 'Challapata, Bolivia → Western Australia, Australia' }, + crossesAntimeridian: true } ]; +// Exact snapshots for non-crossing routes only. +// Splitting correctness for crossing routes (indices 2–5) is owned by antimeridian.test.ts. +// Integration tests verify output format and property pass-through. const expectedArcs = [ { - "properties": { "name": "Seattle to DC" }, + "properties": { "name": "Seattle → DC" }, "geometries": [{ "coords": [ [-122, 48], @@ -53,7 +64,7 @@ const expectedArcs = [ }] }, { - "properties": { "name": "Seattle to London" }, + "properties": { "name": "Seattle → London" }, "geometries": [{ "coords": [ [-122, 48], @@ -62,61 +73,12 @@ const expectedArcs = [ ], "length": 3 }] - }, - { - "properties": { "name": "crosses dateline 1" }, - "geometries": [{ - "coords": [ - [-75.9375, 35.46067], - [-136.823034, -10.367409], - [146.25, -43.068888] - ], - "length": 3 - }] - }, - { - "properties": { "name": "crosses dateline 2" }, - "geometries": [{ - "coords": [ - [145.546875, 48.458352], - [-157.284841, 8.442054], - [-112.5, -37.71859] - ], - "length": 3 - }] - }, - { - "properties": { "name": "south 1" }, - "geometries": [{ - "coords": [ - [-74.564209, -0.175781], - [-140.443271, -35.801086], - [137.779541, -22.755921] - ], - "length": 3 - }] - }, - { - "properties": { "name": "south 2" }, - "geometries": [{ - "coords": [ - [-66.829834, -18.812718], - [-146.781778, -82.179503], - [118.795166, -20.797201] - ], - "length": 3 - }] } ]; -// Expected WKT results (precise values for regression testing) const expectedWkts = [ 'LINESTRING(-122 48,-97.728086 45.753682,-77 39)', 'LINESTRING(-122 48,-64.165901 67.476242,0 51)', - 'LINESTRING(-75.9375 35.46067,-136.823034 -10.367409,146.25 -43.068888)', - 'LINESTRING(145.546875 48.458352,-157.284841 8.442054,-112.5 -37.71859)', - 'LINESTRING(-74.564209 -0.175781,-140.443271 -35.801086,137.779541 -22.755921)', - 'LINESTRING(-66.829834 -18.812718,-146.781778 -82.179503,118.795166 -20.797201)', ]; describe('Integration', () => { @@ -125,12 +87,20 @@ describe('Integration', () => { test(`Route ${idx} (${route.properties.name}) should match expected output`, () => { const gc = new GreatCircle(route.start, route.end, route.properties); const line = gc.Arc(3); - - // Test internal structure matches expected - expect(JSON.stringify(line)).toEqual(JSON.stringify(expectedArcs[idx])); - - // Test WKT output matches expected - expect(line.wkt()).toBe(expectedWkts[idx]); + + if (!route.crossesAntimeridian) { + // Non-crossing routes: exact snapshot (LineString structure is stable) + expect(JSON.stringify(line)).toEqual(JSON.stringify(expectedArcs[idx])); + expect(line.wkt()).toBe(expectedWkts[idx]); + } else { + // Crossing routes: verify output format and property pass-through only. + // Splitting correctness (MultiLineString, ±180 boundaries) is in antimeridian.test.ts. + const geojson = line.json(); + expect(geojson.type).toBe('Feature'); + expect(geojson.properties).toEqual(route.properties); + // WKT serializer must produce two LINESTRING parts for split routes + expect(line.wkt()).toContain('; '); + } }); }); }); @@ -141,14 +111,12 @@ describe('Integration', () => { const gc = new GreatCircle(route.start, route.end, route.properties); const line = gc.Arc(3); const geojson = line.json(); - - // Validate GeoJSON structure + expect(geojson.type).toBe('Feature'); expect(geojson.geometry).toBeDefined(); expect(geojson.properties).toBeDefined(); expect(geojson.properties).toEqual(route.properties); - - // Validate coordinates exist and are array + expect('coordinates' in geojson.geometry).toBe(true); const coords = (geojson.geometry as any).coordinates; expect(Array.isArray(coords)).toBe(true); @@ -157,40 +125,25 @@ describe('Integration', () => { }); }); - describe('Dateline crossing behavior', () => { - const datelineCrossingRoutes = routes.filter(route => - route.properties.name.includes('crosses dateline') + describe('Southern hemisphere routes', () => { + const southernRoutes = routes.filter(route => + route.start.y < 0 || route.end.y < 0 ); - datelineCrossingRoutes.forEach((route, idx) => { - test(`${route.properties.name} should handle dateline crossing`, () => { + southernRoutes.forEach((route) => { + test(`${route.properties.name} should produce coordinates with southern latitudes`, () => { const gc = new GreatCircle(route.start, route.end, route.properties); const line = gc.Arc(3); - - expect(line.geometries.length).toBeGreaterThan(0); - - const coords = (line.json().geometry as any).coordinates; - expect(coords.length).toBeGreaterThan(0); - }); - }); - }); - describe('Southern hemisphere routes', () => { - const southernRoutes = routes.filter(route => - route.properties.name.includes('south') - ); + // Flatten MultiLineString coordinates before checking for southern latitudes. + // Without flattening, coords.some() iterates over number[][] (sub-arrays), + // not number[] (individual points), so coord[1] would be an array, not a latitude. + const geojson = line.json(); + const allCoords: number[][] = geojson.geometry.type === 'MultiLineString' + ? (geojson.geometry as MultiLineString).coordinates.flat() + : (geojson.geometry as LineString).coordinates; - southernRoutes.forEach((route, idx) => { - test(`${route.properties.name} should handle southern hemisphere`, () => { - const gc = new GreatCircle(route.start, route.end, route.properties); - const line = gc.Arc(3); - - expect(line.geometries.length).toBeGreaterThan(0); - - // Check that some coordinates have southern latitudes - const coords = (line.json().geometry as any).coordinates; - expect(Array.isArray(coords)).toBe(true); - const hasSouthernLatitudes = coords.some((coord: number[]) => { + const hasSouthernLatitudes = allCoords.some((coord: number[]) => { return Array.isArray(coord) && coord.length > 1 && typeof coord[1] === 'number' && coord[1] < 0; }); expect(hasSouthernLatitudes).toBe(true); @@ -200,20 +153,17 @@ describe('Integration', () => { describe('Full workflow test', () => { test('should complete full workflow from coordinates to output formats', () => { - const testRoute = routes[0]!; // Seattle to DC - non-null assertion since we know it exists - + const testRoute = routes[0]!; // Seattle → DC + const gc = new GreatCircle(testRoute.start, testRoute.end, testRoute.properties); const line = gc.Arc(3); - - // Test Arc instance + expect(line).toBeDefined(); expect(line.properties).toEqual(testRoute.properties); - - // Test GeoJSON output + const geojson = line.json(); expect(geojson.type).toBe('Feature'); - - // Test WKT output + const wkt = line.wkt(); expect(typeof wkt).toBe('string'); expect(wkt.startsWith('LINESTRING')).toBe(true);