Skip to content

Commit 4cac6cd

Browse files
committed
Fixes to multiple modules that were performing scalar (2D) calculations when they should have been doing spherical (3D). Most changes were to nearestPointOnLine. pointToLineDistance now uses nearestPointOnLine for spherical calculations. Flow on corrections impacted lineSlice and nearestPointToLine as well.
Tidied up some tests - fixed module name in test definitions, added a benchmark overall time in a few places for easier comparisons.
1 parent 2907628 commit 4cac6cd

38 files changed

+3339
-3038
lines changed

packages/turf-line-slice/test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
55
import { loadJsonFileSync } from "load-json-file";
66
import { writeJsonFileSync } from "write-json-file";
77
import { truncate } from "@turf/truncate";
8-
import { featureCollection } from "@turf/helpers";
8+
import { featureCollection, point, lineString } from "@turf/helpers";
99
import { lineSlice } from "./index.js";
1010

1111
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -38,3 +38,24 @@ test("turf-line-slice", (t) => {
3838
}
3939
t.end();
4040
});
41+
42+
test("turf-nearest-point-on-line -- issue 2023", (t) => {
43+
const ptStart = point([3.69140625, 51.72702815704774]);
44+
const ptEnd = point([0.31936718356317106, 47.93913163509963]);
45+
const line = lineString([
46+
[3.69140625, 51.72702815704774],
47+
[-5.3173828125, 41.60722821271717],
48+
]);
49+
50+
const slice = lineSlice(ptStart, ptEnd, line);
51+
52+
t.deepEqual(
53+
truncate(slice, { precision: 8 }).geometry.coordinates,
54+
[
55+
[3.69140625, 51.72702816],
56+
[-0.03079923, 48.08596086],
57+
],
58+
"slice should be [[3.69140625, 51.72702816], [-0.03079923, 48.08596086]]"
59+
);
60+
t.end();
61+
});

packages/turf-line-slice/test/out/line1.geojson

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,9 @@
3838
"geometry": {
3939
"type": "LineString",
4040
"coordinates": [
41-
[-97.835729, 22.247393],
41+
[-97.835747, 22.247595],
4242
[-97.820892, 22.17596],
43-
[-97.738467, 22.051207]
43+
[-97.738477, 22.051413]
4444
]
4545
}
4646
}

packages/turf-line-slice/test/out/line2.geojson

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737
"geometry": {
3838
"type": "LineString",
3939
"coordinates": [
40-
[0.049987, 0.049987],
41-
[0.849858, 0.849858]
40+
[0.05, 0.050008],
41+
[0.849981, 0.850017]
4242
]
4343
}
4444
}

packages/turf-line-slice/test/out/route2.geojson

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3796,7 +3796,7 @@
37963796
"geometry": {
37973797
"type": "LineString",
37983798
"coordinates": [
3799-
[-111.895792, 48.877416],
3799+
[-111.895843, 48.877468],
38003800
[-111.878176, 48.860393],
38013801
[-111.867242, 48.849753],
38023802
[-111.866486, 48.849013],

packages/turf-nearest-point-on-line/bench.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "fs";
22
import path from "path";
33
import { fileURLToPath } from "url";
44
import { loadJsonFileSync } from "load-json-file";
5-
import Benchmark from "benchmark";
5+
import Benchmark, { Event } from "benchmark";
66
import { nearestPointOnLine } from "./index.js";
77

88
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -29,10 +29,19 @@ const fixtures = fs.readdirSync(directory).map((filename) => {
2929
* route1 x 195 ops/sec ±2.23% (80 runs sampled)
3030
* route2 x 218 ops/sec ±2.42% (78 runs sampled)
3131
*/
32+
let totalTime = 0.0;
3233
const suite = new Benchmark.Suite("turf-nearest-point-on-line");
3334
for (const { name, geojson } of fixtures) {
3435
const [line, point] = geojson.features;
35-
suite.add(name, () => nearestPointOnLine(line, point));
36+
suite.add(name, () => nearestPointOnLine(line, point), {
37+
onComplete: (e: Event) =>
38+
(totalTime = totalTime += e.target.times?.elapsed),
39+
});
3640
}
3741

38-
suite.on("cycle", (e) => console.log(String(e.target))).run();
42+
suite
43+
.on("cycle", (e: Event) => console.log(String(e.target)))
44+
.on("complete", () =>
45+
console.log(`completed in ${totalTime.toFixed(2)} seconds`)
46+
)
47+
.run();
Lines changed: 162 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
import { Feature, Point, LineString, MultiLineString } from "geojson";
2-
import { bearing } from "@turf/bearing";
1+
import { Feature, Point, Position, LineString, MultiLineString } from "geojson";
32
import { distance } from "@turf/distance";
4-
import { destination } from "@turf/destination";
5-
import { lineIntersect as lineIntersects } from "@turf/line-intersect";
63
import { flattenEach } from "@turf/meta";
7-
import { point, lineString, Coord, Units } from "@turf/helpers";
8-
import { getCoords } from "@turf/invariant";
4+
import { point, Coord, Units } from "@turf/helpers";
5+
import { getCoord, getCoords } from "@turf/invariant";
96

107
/**
118
* Takes a {@link Point} and a {@link LineString} and calculates the closest Point on the (Multi)LineString.
@@ -51,6 +48,8 @@ function nearestPointOnLine<G extends LineString | MultiLineString>(
5148
throw new Error("lines and pt are required arguments");
5249
}
5350

51+
const ptPos = getCoord(pt);
52+
5453
let closestPt: Feature<
5554
Point,
5655
{ dist: number; index: number; multiFeatureIndex: number; location: number }
@@ -68,80 +67,48 @@ function nearestPointOnLine<G extends LineString | MultiLineString>(
6867
const coords: any = getCoords(line);
6968

7069
for (let i = 0; i < coords.length - 1; i++) {
71-
//start
70+
//start - start of current line section
7271
const start: Feature<Point, { dist: number }> = point(coords[i]);
7372
start.properties.dist = distance(pt, start, options);
74-
//stop
73+
const startPos = getCoord(start);
74+
75+
//stop - end of current line section
7576
const stop: Feature<Point, { dist: number }> = point(coords[i + 1]);
7677
stop.properties.dist = distance(pt, stop, options);
78+
const stopPos = getCoord(stop);
79+
7780
// sectionLength
7881
const sectionLength = distance(start, stop, options);
79-
//perpendicular
80-
const heightDistance = Math.max(
81-
start.properties.dist,
82-
stop.properties.dist
83-
);
84-
const direction = bearing(start, stop);
85-
const perpendicularPt1 = destination(
86-
pt,
87-
heightDistance,
88-
direction + 90,
89-
options
90-
);
91-
const perpendicularPt2 = destination(
92-
pt,
93-
heightDistance,
94-
direction - 90,
95-
options
96-
);
97-
const intersect = lineIntersects(
98-
lineString([
99-
perpendicularPt1.geometry.coordinates,
100-
perpendicularPt2.geometry.coordinates,
101-
]),
102-
lineString([start.geometry.coordinates, stop.geometry.coordinates])
103-
);
82+
let intersectPos: Position;
83+
let wasEnd: boolean;
84+
85+
// Short circuit if snap point is start or end position of the line
86+
// segment.
87+
if (startPos[0] === ptPos[0] && startPos[1] === ptPos[1]) {
88+
[intersectPos, , wasEnd] = [startPos, undefined, false];
89+
} else if (stopPos[0] === ptPos[0] && stopPos[1] === ptPos[1]) {
90+
[intersectPos, , wasEnd] = [stopPos, undefined, true];
91+
} else {
92+
// Otherwise, find the nearest point the hard way.
93+
[intersectPos, , wasEnd] = nearestPointOnSegment(
94+
start.geometry.coordinates,
95+
stop.geometry.coordinates,
96+
getCoord(pt)
97+
);
98+
}
10499
let intersectPt:
105100
| Feature<
106101
Point,
107102
{ dist: number; multiFeatureIndex: number; location: number }
108103
>
109104
| undefined;
110105

111-
if (intersect.features.length > 0 && intersect.features[0]) {
112-
intersectPt = {
113-
...intersect.features[0],
114-
properties: {
115-
dist: distance(pt, intersect.features[0], options),
116-
multiFeatureIndex: multiFeatureIndex,
117-
location:
118-
length + distance(start, intersect.features[0], options),
119-
},
120-
};
121-
}
122-
123-
if (start.properties.dist < closestPt.properties.dist) {
124-
closestPt = {
125-
...start,
126-
properties: {
127-
...start.properties,
128-
index: i,
129-
multiFeatureIndex: multiFeatureIndex,
130-
location: length,
131-
},
132-
};
133-
}
134-
135-
if (stop.properties.dist < closestPt.properties.dist) {
136-
closestPt = {
137-
...stop,
138-
properties: {
139-
...stop.properties,
140-
index: i + 1,
141-
multiFeatureIndex: multiFeatureIndex,
142-
location: length + sectionLength,
143-
},
144-
};
106+
if (intersectPos) {
107+
intersectPt = point(intersectPos, {
108+
dist: distance(pt, intersectPos, options),
109+
multiFeatureIndex: multiFeatureIndex,
110+
location: length + distance(start, intersectPos, options),
111+
});
145112
}
146113

147114
if (
@@ -150,9 +117,15 @@ function nearestPointOnLine<G extends LineString | MultiLineString>(
150117
) {
151118
closestPt = {
152119
...intersectPt,
153-
properties: { ...intersectPt.properties, index: i },
120+
properties: {
121+
...intersectPt.properties,
122+
// Legacy behaviour where index progresses to next segment # if we
123+
// went with the end point this iteration.
124+
index: wasEnd ? i + 1 : i,
125+
},
154126
};
155127
}
128+
156129
// update length
157130
length += sectionLength;
158131
}
@@ -162,5 +135,126 @@ function nearestPointOnLine<G extends LineString | MultiLineString>(
162135
return closestPt;
163136
}
164137

138+
type Vector = [number, number, number];
139+
140+
function dot(v1: Vector, v2: Vector): number {
141+
const [v1x, v1y, v1z] = v1;
142+
const [v2x, v2y, v2z] = v2;
143+
return v1x * v2x + v1y * v2y + v1z * v2z;
144+
}
145+
146+
// https://en.wikipedia.org/wiki/Cross_product
147+
function cross(v1: Vector, v2: Vector): Vector {
148+
const [v1x, v1y, v1z] = v1;
149+
const [v2x, v2y, v2z] = v2;
150+
return [v1y * v2z - v1z * v2y, v1z * v2x - v1x * v2z, v1x * v2y - v1y * v2x];
151+
}
152+
153+
function magnitude(v: Vector) {
154+
return Math.sqrt(Math.pow(v[0], 2) + Math.pow(v[1], 2) + Math.pow(v[2], 2));
155+
}
156+
157+
function angle(v1: Vector, v2: Vector): number {
158+
const theta = dot(v1, v2) / (magnitude(v1) * magnitude(v2));
159+
return Math.acos(Math.min(Math.max(theta, -1), 1));
160+
}
161+
162+
function toRadians(degrees: number) {
163+
return degrees * (Math.PI / 180);
164+
}
165+
166+
function toDegrees(radians: number) {
167+
return radians * (180 / Math.PI);
168+
}
169+
170+
function lngLatToVector(a: Position): Vector {
171+
const lat = toRadians(a[1]);
172+
const lng = toRadians(a[0]);
173+
return [
174+
Math.cos(lat) * Math.cos(lng),
175+
Math.cos(lat) * Math.sin(lng),
176+
Math.sin(lat),
177+
];
178+
}
179+
180+
function vectorToLngLat(v: Vector): Position {
181+
const [x, y, z] = v;
182+
const lat = toDegrees(Math.asin(z));
183+
const lng = toDegrees(Math.atan2(y, x));
184+
185+
return [lng, lat];
186+
}
187+
188+
function nearestPointOnSegment(
189+
posA: Position,
190+
posB: Position,
191+
posC: Position
192+
): [Position, boolean, boolean] {
193+
// Based heavily on this article on finding cross track distance to an arc:
194+
// https://gis.stackexchange.com/questions/209540/projecting-cross-track-distance-on-great-circle
195+
196+
// Convert lng/lat to spherical coords
197+
const A = lngLatToVector(posA); // the vector from 0,0,0 to posA
198+
const B = lngLatToVector(posB);
199+
const C = lngLatToVector(posC);
200+
201+
// Components of target point.
202+
const [Cx, Cy, Cz] = C;
203+
204+
// Calculate coefficients.
205+
const [D, E, F] = cross(A, B);
206+
const a = E * Cz - F * Cy;
207+
const b = F * Cx - D * Cz;
208+
const c = D * Cy - E * Cx;
209+
210+
const f = c * E - b * F;
211+
const g = a * F - c * D;
212+
const h = b * D - a * E;
213+
214+
const t = 1 / Math.sqrt(Math.pow(f, 2) + Math.pow(g, 2) + Math.pow(h, 2));
215+
216+
// Vectors to the two points these great circles intersect.
217+
const I1: Vector = [f * t, g * t, h * t];
218+
const I2: Vector = [-1 * f * t, -1 * g * t, -1 * h * t];
219+
220+
// Figure out which is the closest intersection to this segment of the great
221+
// circle.
222+
const angleAB = angle(A, B);
223+
const angleAI1 = angle(A, I1);
224+
const angleBI1 = angle(B, I1);
225+
const angleAI2 = angle(A, I2);
226+
const angleBI2 = angle(B, I2);
227+
228+
let I: Vector;
229+
230+
if (
231+
(angleAI1 < angleAI2 && angleAI1 < angleBI2) ||
232+
(angleBI1 < angleAI2 && angleBI1 < angleBI2)
233+
) {
234+
I = I1;
235+
} else {
236+
I = I2;
237+
}
238+
239+
// I is the closest intersection to the segment, though might not actually be
240+
// ON the segment.
241+
242+
// If angle AI or BI is greater than angleAB, I lies on the circle *beyond* A
243+
// and B so use the closest of A or B as the intersection
244+
if (angle(A, I) > angleAB || angle(B, I) > angleAB) {
245+
if (
246+
distance(vectorToLngLat(I), vectorToLngLat(A)) <=
247+
distance(vectorToLngLat(I), vectorToLngLat(B))
248+
) {
249+
return [vectorToLngLat(A), true, false];
250+
} else {
251+
return [vectorToLngLat(B), false, true];
252+
}
253+
}
254+
255+
// As angleAI nor angleBI don't exceed angleAB, I is on the segment
256+
return [vectorToLngLat(I), false, false];
257+
}
258+
165259
export { nearestPointOnLine };
166260
export default nearestPointOnLine;

packages/turf-nearest-point-on-line/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,9 @@
6161
"write-json-file": "^5.0.0"
6262
},
6363
"dependencies": {
64-
"@turf/bearing": "workspace:^",
65-
"@turf/destination": "workspace:^",
6664
"@turf/distance": "workspace:^",
6765
"@turf/helpers": "workspace:^",
6866
"@turf/invariant": "workspace:^",
69-
"@turf/line-intersect": "workspace:^",
7067
"@turf/meta": "workspace:^",
7168
"@types/geojson": "^7946.0.10",
7269
"tslib": "^2.6.2"

0 commit comments

Comments
 (0)