From 2c7d07162b9a7bd0186e045b678272e53bd991a7 Mon Sep 17 00:00:00 2001 From: JSap0914 Date: Sat, 27 Jun 2026 18:46:40 +0900 Subject: [PATCH 1/3] fix(rhumb-bearing): return NaN for coincident points When start and end points are identical, rhumbBearing() was returning 0 (north) because Math.atan2(0, 0) === 0. This is misleading since no bearing can be defined for a zero-length segment. The Geodesy library (which this implementation is adapted from) explicitly guards for this case and returns NaN. Align turf's behaviour accordingly. Closes #2478 --- packages/turf-rhumb-bearing/index.ts | 4 ++++ packages/turf-rhumb-bearing/test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/turf-rhumb-bearing/index.ts b/packages/turf-rhumb-bearing/index.ts index c3d48ccc9f..f821a31d11 100644 --- a/packages/turf-rhumb-bearing/index.ts +++ b/packages/turf-rhumb-bearing/index.ts @@ -54,6 +54,10 @@ function rhumbBearing( * var d = p1.rhumbBearingTo(p2); // 116.7 m */ function calculateRhumbBearing(from: number[], to: number[]) { + // Coincident points have no defined bearing (matches Geodesy reference impl) + if (from[0] === to[0] && from[1] === to[1]) { + return NaN; + } // φ => phi // Δλ => deltaLambda // Δψ => deltaPsi diff --git a/packages/turf-rhumb-bearing/test.ts b/packages/turf-rhumb-bearing/test.ts index 377a0c9a41..a9f40767fc 100644 --- a/packages/turf-rhumb-bearing/test.ts +++ b/packages/turf-rhumb-bearing/test.ts @@ -46,6 +46,18 @@ test("bearing", (t) => { ); }); + // Coincident points have no defined bearing — must return NaN, not 0 + // Regression test for https://github.com/Turfjs/turf/issues/2478 + const coincident = point([5, 5]); + t.ok( + isNaN(rhumbBearing(coincident, coincident)), + "coincident points return NaN" + ); + t.ok( + isNaN(rhumbBearing(coincident, coincident, { final: true })), + "coincident points return NaN (final bearing)" + ); + t.throws(() => { rhumbBearing(point([12, -54]), "point"); }, "invalid point"); From 943087cff1c8a829aef466e2f819d0f1d80b48fc Mon Sep 17 00:00:00 2001 From: JSap0914 Date: Sat, 27 Jun 2026 18:49:35 +0900 Subject: [PATCH 2/3] fix(@turf/polygon-tangents): return distinct tangents when nearest vertex is below viewpoint inside bbox When the viewpoint lies inside the polygon bounding box and the nearest polygon vertex is below the viewpoint, the existing code initialises both rtan and ltan to that same nearest vertex. processPolygon then cannot update ltan because every above-viewpoint candidate compares as 'above' the current ltan (which is already below the viewpoint), so the function returns the same point for both tangents. Detect this degenerate outcome (rtan === ltan after processPolygon) and retry with ltan reset to vertex[0], which lets the traversal find the correct left-tangent vertex. Fixes #2898 --- packages/turf-polygon-tangents/index.ts | 20 ++++++ packages/turf-polygon-tangents/test.ts | 21 ++++++ .../test/in/issue#2898.geojson | 29 ++++++++ .../test/out/issue#2898.geojson | 69 +++++++++++++++++++ 4 files changed, 139 insertions(+) create mode 100644 packages/turf-polygon-tangents/test/in/issue#2898.geojson create mode 100644 packages/turf-polygon-tangents/test/out/issue#2898.geojson diff --git a/packages/turf-polygon-tangents/index.ts b/packages/turf-polygon-tangents/index.ts index 7e27783ca6..7d62105131 100644 --- a/packages/turf-polygon-tangents/index.ts +++ b/packages/turf-polygon-tangents/index.ts @@ -75,6 +75,26 @@ function polygonTangents( rtan, ltan ); + // When the nearest-vertex initialisation sets ltan === rtan (both below + // the viewpoint), processPolygon may fail to find a distinct left tangent + // because every above-viewpoint candidate is considered "above" the + // current ltan and therefore not chosen. Detect this degenerate result + // and retry with ltan reset to vertex[0] so the traversal can pick the + // correct left tangent. See https://github.com/Turfjs/turf/issues/2898 + if ( + nearestPtIndex !== 0 && + rtan[0] === ltan[0] && + rtan[1] === ltan[1] + ) { + ltan = polyCoords[0][0]; + [rtan, ltan] = processPolygon( + polyCoords[0], + pointCoords, + eprev, + rtan, + ltan + ); + } break; case "MultiPolygon": var closestFeature = 0; diff --git a/packages/turf-polygon-tangents/test.ts b/packages/turf-polygon-tangents/test.ts index cae2f35ab5..fe9cadb340 100644 --- a/packages/turf-polygon-tangents/test.ts +++ b/packages/turf-polygon-tangents/test.ts @@ -109,3 +109,24 @@ test("turf-polygon-tangents - Issue #1050", (t) => { } t.end(); }); + +test("turf-polygon-tangents - Issue #2898", (t) => { + // When the viewpoint is inside the polygon bbox and the nearest vertex is + // below the viewpoint, polygonTangents previously returned the same point + // for both tangents instead of two distinct tangent vertices. + const pt = point([-79.41285676230808, 43.627309605975235]); + const poly = polygon([ + [ + [-79.428776, 43.708224], + [-79.325734, 43.675502], + [-79.414155, 43.595383], + [-79.353931, 43.670969], + [-79.428776, 43.708224], + ], + ]); + const tangents = polygonTangents(pt, poly); + t.equal(tangents.features.length, 2, "returns two features"); + const [t1, t2] = tangents.features.map((f) => f.geometry.coordinates); + t.notDeepEqual(t1, t2, "tangent points must be distinct vertices"); + t.end(); +}); diff --git a/packages/turf-polygon-tangents/test/in/issue#2898.geojson b/packages/turf-polygon-tangents/test/in/issue#2898.geojson new file mode 100644 index 0000000000..e13e8691d2 --- /dev/null +++ b/packages/turf-polygon-tangents/test/in/issue#2898.geojson @@ -0,0 +1,29 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-79.428776, 43.708224], + [-79.325734, 43.675502], + [-79.414155, 43.595383], + [-79.353931, 43.670969], + [-79.428776, 43.708224] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [-79.41285676230808, 43.627309605975235] + } + } + ] +} diff --git a/packages/turf-polygon-tangents/test/out/issue#2898.geojson b/packages/turf-polygon-tangents/test/out/issue#2898.geojson new file mode 100644 index 0000000000..d09e2e6b5e --- /dev/null +++ b/packages/turf-polygon-tangents/test/out/issue#2898.geojson @@ -0,0 +1,69 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + -79.414155, + 43.595383 + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + -79.428776, + 43.708224 + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -79.428776, + 43.708224 + ], + [ + -79.325734, + 43.675502 + ], + [ + -79.414155, + 43.595383 + ], + [ + -79.353931, + 43.670969 + ], + [ + -79.428776, + 43.708224 + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [ + -79.41285676230808, + 43.627309605975235 + ] + } + } + ] +} \ No newline at end of file From 8964a3d5748e0239e7a18736deaadb92af0f4d40 Mon Sep 17 00:00:00 2001 From: JSap0914 Date: Sat, 27 Jun 2026 23:22:26 +0900 Subject: [PATCH 3/3] chore: remove unrelated rhumb-bearing changes (belong to #3082) --- packages/turf-rhumb-bearing/index.ts | 4 ---- packages/turf-rhumb-bearing/test.ts | 12 ------------ 2 files changed, 16 deletions(-) diff --git a/packages/turf-rhumb-bearing/index.ts b/packages/turf-rhumb-bearing/index.ts index f821a31d11..c3d48ccc9f 100644 --- a/packages/turf-rhumb-bearing/index.ts +++ b/packages/turf-rhumb-bearing/index.ts @@ -54,10 +54,6 @@ function rhumbBearing( * var d = p1.rhumbBearingTo(p2); // 116.7 m */ function calculateRhumbBearing(from: number[], to: number[]) { - // Coincident points have no defined bearing (matches Geodesy reference impl) - if (from[0] === to[0] && from[1] === to[1]) { - return NaN; - } // φ => phi // Δλ => deltaLambda // Δψ => deltaPsi diff --git a/packages/turf-rhumb-bearing/test.ts b/packages/turf-rhumb-bearing/test.ts index a9f40767fc..377a0c9a41 100644 --- a/packages/turf-rhumb-bearing/test.ts +++ b/packages/turf-rhumb-bearing/test.ts @@ -46,18 +46,6 @@ test("bearing", (t) => { ); }); - // Coincident points have no defined bearing — must return NaN, not 0 - // Regression test for https://github.com/Turfjs/turf/issues/2478 - const coincident = point([5, 5]); - t.ok( - isNaN(rhumbBearing(coincident, coincident)), - "coincident points return NaN" - ); - t.ok( - isNaN(rhumbBearing(coincident, coincident, { final: true })), - "coincident points return NaN (final bearing)" - ); - t.throws(() => { rhumbBearing(point([12, -54]), "point"); }, "invalid point");