Skip to content

Commit b4a8d1d

Browse files
committed
refactor(great-circle): extract bisection constant and add inline comments
- Extract 50-iteration bisection count into ANTIMERIDIAN_BISECTION_ITERATIONS constant - Add inline comments explaining each phase of the Arc() algorithm - Clean up comments across src/ for clarity and consistency - Add Thomas Hervey to contributors in package.json
1 parent b154d4d commit b4a8d1d

5 files changed

Lines changed: 43 additions & 34 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
],
1515
"contributors": [
1616
"Dane Springmeyer <dane.springmeyer@gmail.com>",
17-
"John Gravois <jagravois@gmail.com>"
17+
"John Gravois <jagravois@gmail.com>",
18+
"Thomas Hervey <thomasahervey@gmail.com>"
1819
],
1920
"repository": {
2021
"type": "git",

src/arc.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import type { Position } from 'geojson';
44

55
/**
66
* Arc class representing the result of great circle calculations
7-
*
7+
*
88
* @param properties - Optional properties object
9-
*
9+
*
1010
* @example
1111
* ```typescript
1212
* const arc = new Arc({ x: 45.123456789, y: 50.987654321 });
@@ -23,14 +23,14 @@ export class Arc {
2323

2424
/**
2525
* Convert to GeoJSON Feature
26-
*
26+
*
2727
* @returns GeoJSON Feature with LineString or MultiLineString geometry
28-
*
28+
*
2929
* @example
3030
* ```typescript
3131
* const gc = new GreatCircle({x: -122, y: 48}, {x: -77, y: 39});
3232
* const arc = gc.Arc(3);
33-
* console.log(arc.json());
33+
* console.log(arc.json());
3434
* // { type: 'Feature', geometry: { type: 'LineString', coordinates: [[-122, 48], [-99.5, 43.5], [-77, 39]] }, properties: {} }
3535
* ```
3636
*/
@@ -39,8 +39,7 @@ export class Arc {
3939
if (this.geometries.length === 0) {
4040
return {
4141
type: 'Feature',
42-
// NOTE: coordinates: null is non-standard GeoJSON (RFC 7946 specifies empty array [])
43-
// but maintained for backward compatibility with original arc.js behavior
42+
// NOTE: coordinates: null is non-standard GeoJSON (RFC 7946 specifies empty array []) but maintained for backward compatibility with original arc.js behavior.
4443
geometry: { type: 'LineString', coordinates: null as any },
4544
properties: this.properties
4645
};
@@ -78,9 +77,9 @@ export class Arc {
7877

7978
/**
8079
* Convert to WKT (Well Known Text) format
81-
*
80+
*
8281
* @returns WKT string representation
83-
*
82+
*
8483
* @example
8584
* ```typescript
8685
* const arc = new Arc({ name: 'test-arc' });
@@ -93,7 +92,7 @@ export class Arc {
9392
}
9493

9594
let wktParts: string[] = [];
96-
95+
9796
for (const geometry of this.geometries) {
9897
if (!geometry || geometry.coords.length === 0) {
9998
wktParts.push('LINESTRING EMPTY');

src/great-circle.ts

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@ import { Arc } from './arc.js';
44
import { _LineString } from './line-string.js';
55
import { roundCoords, R2D } from './utils.js';
66

7+
// Number of bisection iterations used to locate the antimeridian crossing.
8+
// More iterations = higher precision but more interpolate() calls.
9+
// 50 iterations yields sub-degree precision, which is more than sufficient for most web mapping applications (i.e., not survey grade).
10+
const ANTIMERIDIAN_BISECTION_ITERATIONS = 50;
11+
712

813
/**
914
* Great Circle calculation class
1015
* http://en.wikipedia.org/wiki/Great-circle_distance
11-
*
16+
*
1217
* @param start - Start point
1318
* @param end - End point
1419
* @param properties - Optional properties object
15-
*
20+
*
1621
* @example
1722
* ```typescript
1823
* const greatCircle = new GreatCircle({ x: 45.123456789, y: 50.987654321 }, { x: 46.123456789, y: 51.987654321 });
@@ -32,7 +37,7 @@ export class GreatCircle {
3237
if (!end || end.x === undefined || end.y === undefined) {
3338
throw new Error("GreatCircle constructor expects two args: start and end objects with x and y properties");
3439
}
35-
40+
3641
this.start = new Coord(start.x, start.y);
3742
this.end = new Coord(end.x, end.y);
3843
this.properties = properties || {};
@@ -55,10 +60,10 @@ export class GreatCircle {
5560
/**
5661
* Interpolate along the great circle
5762
* http://williams.best.vwh.net/avform.htm#Intermediate
58-
*
63+
*
5964
* @param f - Interpolation factor
6065
* @returns Interpolated point
61-
*
66+
*
6267
* @example
6368
* ```typescript
6469
* const greatCircle = new GreatCircle({ x: 45.123456789, y: 50.987654321 }, { x: 46.123456789, y: 51.987654321 });
@@ -78,11 +83,11 @@ export class GreatCircle {
7883

7984
/**
8085
* Generate points along the great circle
81-
*
86+
*
8287
* @param npoints - Number of points to generate
8388
* @param options - Optional options object
8489
* @returns Arc object
85-
*
90+
*
8691
* @example
8792
* ```typescript
8893
* const greatCircle = new GreatCircle({ x: 45.123456789, y: 50.987654321 }, { x: 46.123456789, y: 51.987654321 });
@@ -91,11 +96,7 @@ export class GreatCircle {
9196
*/
9297
Arc(npoints: number = 100, _options?: ArcOptions): Arc {
9398
// NOTE: With npoints ≤ 2, no antimeridian splitting is performed.
94-
// A 2-point antimeridian route returns a single LineString spanning ±180°.
95-
// Renderers that support coordinate wrapping (e.g. MapLibre GL JS) handle this
96-
// correctly, whereas splitting would produce two disconnected straight-line stubs
97-
// with no great-circle curvature — arguably worse behavior. This is a known
98-
// limitation; open for maintainer discussion if a MultiLineString split is preferred.
99+
// 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.
99100
if (!npoints || npoints <= 2) {
100101
const arc = new Arc(this.properties);
101102
const line = new _LineString();
@@ -105,19 +106,16 @@ export class GreatCircle {
105106
return arc;
106107
}
107108

108-
// NOTE: options.offset was previously used as dfDateLineOffset in the GDAL-ported
109-
// heuristic. It is kept in ArcOptions for backwards compatibility but is a no-op here.
109+
// 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.
110110

111+
// Sample npoints evenly spaced positions along the great circle arc.
111112
const delta = 1.0 / (npoints - 1);
112113
const first_pass: [number, number][] = [];
113114
for (let i = 0; i < npoints; ++i) {
114115
first_pass.push(this.interpolate(delta * i));
115116
}
116117

117-
// Analytical antimeridian splitting via bisection.
118-
// For each consecutive pair of points where |Δlon| > 180 (opposite sides of ±180°),
119-
// binary-search for the exact crossing fraction f* using interpolate(), then insert
120-
// [±180, lat*] boundary points and start a new segment. 50 iterations → sub-nanodegree precision.
118+
// Walk the sampled points, splitting into segments wherever the arc crosses the antimeridian.
121119
const segments: [number, number][][] = [];
122120
let current: [number, number][] = [];
123121

@@ -131,21 +129,25 @@ export class GreatCircle {
131129

132130
const prev = first_pass[i - 1]!;
133131

132+
// A longitude jump > 180° between adjacent samples indicates an antimeridian crossing.
134133
if (Math.abs(pt[0] - prev[0]) > 180) {
134+
// Bisect to find the interpolation fraction f* at which the arc crosses ±180°.
135135
let lo = delta * (i - 1);
136136
let hi = delta * i;
137137

138-
for (let iter = 0; iter < 50; iter++) {
138+
for (let iter = 0; iter < ANTIMERIDIAN_BISECTION_ITERATIONS; iter++) {
139139
const mid = (lo + hi) / 2;
140140
const [midLon] = this.interpolate(mid);
141141
const [loLon] = this.interpolate(lo);
142+
// If mid and lo are on the same side of ±180°, the crossing is in [mid, hi].
142143
if (Math.abs(midLon - loLon) < 180) {
143144
lo = mid;
144145
} else {
145146
hi = mid;
146147
}
147148
}
148149

150+
// Compute the latitude at the crossing point and close/open segments at ±180°.
149151
const [, crossingLat] = this.interpolate((lo + hi) / 2);
150152
const fromEast = prev[0] > 0;
151153

@@ -161,6 +163,7 @@ export class GreatCircle {
161163
segments.push(current);
162164
}
163165

166+
// Build one LineString per segment and collect them into an Arc.
164167
const arc = new Arc(this.properties);
165168
for (const seg of segments) {
166169
const line = new _LineString();

src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,10 @@ export { GreatCircle } from './great-circle.js';
55
export { roundCoords, D2R, R2D } from './utils.js';
66

77
// Export types
8-
export type { CoordinatePoint, ArcOptions, GeoJSONFeature, LineString, MultiLineString } from './types.js';
8+
export type {
9+
ArcOptions,
10+
CoordinatePoint,
11+
GeoJSONFeature,
12+
LineString,
13+
MultiLineString
14+
} from './types.js';

src/utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import type { Position } from './types.js';
22

33
/**
44
* Round coordinate decimal values to 6 places for precision
5-
*
5+
*
66
* @param coords - A coordinate position (longitude, latitude, optional elevation)
77
* @returns Rounded coordinate position
8-
*
8+
*
99
* @example
1010
* ```typescript
1111
* const coords = [45.123456789, 50.987654321];
@@ -22,7 +22,7 @@ export function roundCoords(coords: Position): Position {
2222
for (let i = 0; i < coords.length; i++) {
2323
const coord = coords[i];
2424
if (coord !== undefined) {
25-
// https://stackoverflow.com/questions/11832914/how-to-round-to-at-most-2-decimal-places-if-necessary
25+
// NOTE: This logic follows https://stackoverflow.com/questions/11832914/how-to-round-to-at-most-2-decimal-places-if-necessary
2626
rounded[i] = Math.round(
2727
(coord + Number.EPSILON) * MULTIPLIER
2828
) / MULTIPLIER;

0 commit comments

Comments
 (0)