diff --git a/packages/turf-nearest-point-on-line/README.md b/packages/turf-nearest-point-on-line/README.md index b9e549963..ec728c9c5 100644 --- a/packages/turf-nearest-point-on-line/README.md +++ b/packages/turf-nearest-point-on-line/README.md @@ -23,13 +23,21 @@ this function. The new properties we recommend using as of v7.4 are: multiFeatureIndex, index, location, and dist continue to work as previously until at least the next major release. +`segmentIndex` always refers to the segment on which the nearest point was +found (the segment starting at `coords[segmentIndex]`). When the nearest +point lands exactly on a shared vertex it is *not* advanced to the following +segment. Callers that relied on the previous "favour the next segment" +behaviour (for example turn-by-turn navigation) can reconstruct it with the +[atEndOfSegment][1], [atEndOfLineString][2] and [hasNextSegment][3] +helpers exported by this package. + ### Parameters -* `lines` **([Geometry][1] | [Feature][2]<([LineString][3] | [MultiLineString][4])>)** Lines to snap to -* `inputPoint` **([Geometry][1] | [Feature][2]<[Point][5]> | [Array][6]<[number][7]>)** Point to snap from -* `options` **[Object][8]** Optional parameters (optional, default `{}`) +* `lines` **([Geometry][4] | [Feature][5]<([LineString][6] | [MultiLineString][7])>)** Lines to snap to +* `inputPoint` **([Geometry][4] | [Feature][5]<[Point][8]> | [Array][9]<[number][10]>)** Point to snap from +* `options` **[Object][11]** Optional parameters (optional, default `{}`) - * `options.units` **Units** Supports all valid Turf [Units][9] (optional, default `'kilometers'`) + * `options.units` **Units** Supports all valid Turf [Units][12] (optional, default `'kilometers'`) ### Examples @@ -51,25 +59,103 @@ var addToMap = [line, inputPoint, snapped]; snapped.properties['marker-color'] = '#00f'; ``` -Returns **[Feature][2]<[Point][5]>** closest point on the `lines` to the `inputPoint`. The point will have the following properties: `lineStringIndex`: closest point was found on the nth LineString (only relevant if input is MultiLineString), `segmentIndex`: closest point was found on nth line segment of the LineString, `totalDistance`: distance along the line from the absolute start of the MultiLineString, `lineDistance`: distance along the line from the start of the LineString where the closest point was found, `segmentDistance`: distance along the line from the start of the line segment where the closest point was found, `pointDistance`: distance to the input point. +Returns **[Feature][5]<[Point][8]>** closest point on the `lines` to the `inputPoint`. The point will have the following properties: `lineStringIndex`: closest point was found on the nth LineString (only relevant if input is MultiLineString), `segmentIndex`: closest point was found on nth line segment of the LineString, `totalDistance`: distance along the line from the absolute start of the MultiLineString, `lineDistance`: distance along the line from the start of the LineString where the closest point was found, `segmentDistance`: distance along the line from the start of the line segment where the closest point was found, `pointDistance`: distance to the input point. + +## NearestPoint + +The subset of `nearestPointOnLine`'s output that the segment helpers below +rely on. Any point returned by [nearestPointOnLine][13] satisfies this. + +Type: [Feature][5]<[Point][8], {segmentIndex: [number][10], lineStringIndex: [number][10], : any}> + +## resolveLineStringCoords + +Resolves the array of coordinates for the LineString on which the nearest +point was found, transparently handling Feature vs Geometry inputs and +MultiLineStrings (via lineStringIndex). + +### Parameters + +* `nearestPoint` **[NearestPoint][14]** +* `line` **LineInput** + +Returns **[Array][9]<[Position][15]>** + +## atEndOfSegment + +Returns true if the nearest point lies exactly on the end vertex of its +segment (i.e. its coordinates equal `coords[segmentIndex + 1]`). + +This is useful to restore the previous Turf behaviour of "favouring the next +segment" when the nearest point falls on a shared vertex between two +segments. Combine with [hasNextSegment][3] to decide whether it is safe to +advance `segmentIndex`. + +### Parameters + +* `nearestPoint` **[Feature][5]<[Point][8]>** A point returned by [nearestPointOnLine][13] +* `line` **([Feature][5]<([LineString][6] | [MultiLineString][7])> | [LineString][6] | [MultiLineString][7])** The original line passed to [nearestPointOnLine][13] + +Returns **[boolean][16]** true if the point is at the end vertex of its segment + +## atEndOfLineString + +Returns true if the nearest point lies at the very last vertex of its +LineString. For MultiLineStrings, this checks within the specific +sub-LineString identified by `lineStringIndex`. + +### Parameters + +* `nearestPoint` **[Feature][5]<[Point][8]>** A point returned by [nearestPointOnLine][13] +* `line` **([Feature][5]<([LineString][6] | [MultiLineString][7])> | [LineString][6] | [MultiLineString][7])** The original line passed to [nearestPointOnLine][13] + +Returns **[boolean][16]** true if the point is at the end of the LineString + +## hasNextSegment + +Returns true if there is another segment after the one on which the nearest +point was found, regardless of where on that segment the point landed. Useful +in combination with [atEndOfSegment][1] to determine whether it is safe to +advance to the next segment. + +### Parameters + +* `nearestPoint` **[Feature][5]<[Point][8]>** A point returned by [nearestPointOnLine][13] +* `line` **([Feature][5]<([LineString][6] | [MultiLineString][7])> | [LineString][6] | [MultiLineString][7])** The original line passed to [nearestPointOnLine][13] + +Returns **[boolean][16]** true if a next segment exists + +[1]: #atendofsegment + +[2]: #atendoflinestring + +[3]: #hasnextsegment + +[4]: https://tools.ietf.org/html/rfc7946#section-3.1 + +[5]: https://tools.ietf.org/html/rfc7946#section-3.2 + +[6]: https://tools.ietf.org/html/rfc7946#section-3.1.4 + +[7]: https://tools.ietf.org/html/rfc7946#section-3.1.5 -[1]: https://tools.ietf.org/html/rfc7946#section-3.1 +[8]: https://tools.ietf.org/html/rfc7946#section-3.1.2 -[2]: https://tools.ietf.org/html/rfc7946#section-3.2 +[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array -[3]: https://tools.ietf.org/html/rfc7946#section-3.1.4 +[10]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number -[4]: https://tools.ietf.org/html/rfc7946#section-3.1.5 +[11]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object -[5]: https://tools.ietf.org/html/rfc7946#section-3.1.2 +[12]: https://turfjs.org/docs/api/types/Units -[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array +[13]: #nearestpointonline -[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number +[14]: #nearestpoint -[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object +[15]: https://developer.mozilla.org/docs/Web/API/Position -[9]: https://turfjs.org/docs/api/types/Units +[16]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean diff --git a/packages/turf-nearest-point-on-line/index.ts b/packages/turf-nearest-point-on-line/index.ts index 955bb14d9..9970f585b 100644 --- a/packages/turf-nearest-point-on-line/index.ts +++ b/packages/turf-nearest-point-on-line/index.ts @@ -29,6 +29,14 @@ import { getCoord, getCoords } from "@turf/invariant"; * multiFeatureIndex, index, location, and dist continue to work as previously * until at least the next major release. * + * `segmentIndex` always refers to the segment on which the nearest point was + * found (the segment starting at `coords[segmentIndex]`). When the nearest + * point lands exactly on a shared vertex it is *not* advanced to the following + * segment. Callers that relied on the previous "favour the next segment" + * behaviour (for example turn-by-turn navigation) can reconstruct it with the + * {@link atEndOfSegment}, {@link atEndOfLineString} and {@link hasNextSegment} + * helpers exported by this package. + * * @function * @param {Geometry|Feature} lines Lines to snap to * @param {Geometry|Feature|number[]} inputPoint Point to snap from @@ -125,22 +133,17 @@ function nearestPointOnLine( // segmentLength const segmentLength = distance(start, stop, options); let intersectPos: Position; - let wasEnd: boolean; // Short circuit if snap point is start or end position of the line // Test the end position first for consistency in case they are // coincident if (stopPos[0] === inputPos[0] && stopPos[1] === inputPos[1]) { - [intersectPos, wasEnd] = [stopPos, true]; + intersectPos = stopPos; } else if (startPos[0] === inputPos[0] && startPos[1] === inputPos[1]) { - [intersectPos, wasEnd] = [startPos, false]; + intersectPos = startPos; } else { // Otherwise, find the nearest point the hard way. - [intersectPos, wasEnd] = nearestPointOnSegment( - startPos, - stopPos, - inputPos - ); + [intersectPos] = nearestPointOnSegment(startPos, stopPos, inputPos); } const pointDistance = distance(inputPoint, intersectPos, options); @@ -149,9 +152,11 @@ function nearestPointOnLine( const segmentDistance = distance(start, intersectPos, options); closestPt = point(intersectPos, { lineStringIndex: lineStringIndex, - // Legacy behaviour where index progresses to next segment # if we - // went with the end point this iteration. - segmentIndex: wasEnd ? i + 1 : i, + // segmentIndex always refers to the segment on which the point was + // found (the one starting at coords[i]), never the following one. + // Use atEndOfSegment/hasNextSegment to detect and restore the old + // "favour the next segment" behaviour where it is still needed. + segmentIndex: i, totalDistance: totalDistance + segmentDistance, lineDistance: lineDistance + segmentDistance, segmentDistance: segmentDistance, @@ -183,6 +188,94 @@ function nearestPointOnLine( return closestPt; } +/** + * The subset of `nearestPointOnLine`'s output that the segment helpers below + * rely on. Any point returned by {@link nearestPointOnLine} satisfies this. + */ +type NearestPoint = Feature< + Point, + { segmentIndex: number; lineStringIndex: number; [key: string]: any } +>; + +type LineInput = + | Feature + | LineString + | MultiLineString; + +/** + * Resolves the array of coordinates for the LineString on which the nearest + * point was found, transparently handling Feature vs Geometry inputs and + * MultiLineStrings (via lineStringIndex). + */ +function resolveLineStringCoords( + nearestPoint: NearestPoint, + line: LineInput +): Position[] { + const geom = line.type === "Feature" ? line.geometry : line; + return geom.type === "MultiLineString" + ? geom.coordinates[nearestPoint.properties.lineStringIndex] + : geom.coordinates; +} + +/** + * Returns true if the nearest point lies exactly on the end vertex of its + * segment (i.e. its coordinates equal `coords[segmentIndex + 1]`). + * + * This is useful to restore the previous Turf behaviour of "favouring the next + * segment" when the nearest point falls on a shared vertex between two + * segments. Combine with {@link hasNextSegment} to decide whether it is safe to + * advance `segmentIndex`. + * + * @function + * @param {Feature} nearestPoint A point returned by {@link nearestPointOnLine} + * @param {Feature|LineString|MultiLineString} line The original line passed to {@link nearestPointOnLine} + * @returns {boolean} true if the point is at the end vertex of its segment + */ +function atEndOfSegment(nearestPoint: NearestPoint, line: LineInput): boolean { + const coords = resolveLineStringCoords(nearestPoint, line); + const segmentEnd = coords[nearestPoint.properties.segmentIndex + 1]; + if (!segmentEnd) return false; + + const [npLng, npLat] = nearestPoint.geometry.coordinates; + return npLng === segmentEnd[0] && npLat === segmentEnd[1]; +} + +/** + * Returns true if the nearest point lies at the very last vertex of its + * LineString. For MultiLineStrings, this checks within the specific + * sub-LineString identified by `lineStringIndex`. + * + * @function + * @param {Feature} nearestPoint A point returned by {@link nearestPointOnLine} + * @param {Feature|LineString|MultiLineString} line The original line passed to {@link nearestPointOnLine} + * @returns {boolean} true if the point is at the end of the LineString + */ +function atEndOfLineString( + nearestPoint: NearestPoint, + line: LineInput +): boolean { + const coords = resolveLineStringCoords(nearestPoint, line); + const isLastSegment = + nearestPoint.properties.segmentIndex === coords.length - 2; + return isLastSegment && atEndOfSegment(nearestPoint, line); +} + +/** + * Returns true if there is another segment after the one on which the nearest + * point was found, regardless of where on that segment the point landed. Useful + * in combination with {@link atEndOfSegment} to determine whether it is safe to + * advance to the next segment. + * + * @function + * @param {Feature} nearestPoint A point returned by {@link nearestPointOnLine} + * @param {Feature|LineString|MultiLineString} line The original line passed to {@link nearestPointOnLine} + * @returns {boolean} true if a next segment exists + */ +function hasNextSegment(nearestPoint: NearestPoint, line: LineInput): boolean { + const coords = resolveLineStringCoords(nearestPoint, line); + return nearestPoint.properties.segmentIndex < coords.length - 2; +} + // A simple Vector3 type for cartesian operations. type Vector = [number, number, number]; @@ -319,5 +412,10 @@ function nearestPointOnSegment( } } -export { nearestPointOnLine }; +export { + nearestPointOnLine, + atEndOfSegment, + atEndOfLineString, + hasNextSegment, +}; export default nearestPointOnLine; diff --git a/packages/turf-nearest-point-on-line/package.json b/packages/turf-nearest-point-on-line/package.json index 1681d1358..46bfa8019 100644 --- a/packages/turf-nearest-point-on-line/package.json +++ b/packages/turf-nearest-point-on-line/package.json @@ -7,7 +7,8 @@ "Angel Lacret <@alacret>", "Emil Junker <@EmilJunker>", "Jon Miles <@jonmiles>", - "John Ziebro <@Insighttful>" + "John Ziebro <@Insighttful>", + "Felipe Moran <@Feliperm17>" ], "license": "MIT", "bugs": { diff --git a/packages/turf-nearest-point-on-line/test.ts b/packages/turf-nearest-point-on-line/test.ts index e3d1e969a..1cb1e8e35 100644 --- a/packages/turf-nearest-point-on-line/test.ts +++ b/packages/turf-nearest-point-on-line/test.ts @@ -16,7 +16,12 @@ import { round, degreesToRadians, } from "@turf/helpers"; -import { nearestPointOnLine } from "./index.js"; +import { + nearestPointOnLine, + atEndOfSegment, + atEndOfLineString, + hasNextSegment, +} from "./index.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -333,7 +338,7 @@ test("turf-nearest-point-on-line - segmentIndex and pointDistance", (t) => { const pt = point([-92.110576, 41.040649]); const snapped = truncate(nearestPointOnLine(line, pt)); - t.equal(snapped.properties.segmentIndex, 8, "properties.segmentIndex"); + t.equal(snapped.properties.segmentIndex, 7, "properties.segmentIndex"); t.equal( Number(snapped.properties.pointDistance.toFixed(6)), 0.823802, @@ -662,3 +667,197 @@ test("turf-nearest-point-on-line -- issue 2939 nearestPointOnSegment handles tin t.end(); }); + +test("turf-nearest-point-on-line -- issue 3023 nearest point on last vertex", (t) => { + // Case 1: nearest point lands on the last vertex of the line. segmentIndex + // must point at the last valid segment (coords.length - 2), never overflow + // to coords.length - 1. + const line = lineString([ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + ]); + + const result = nearestPointOnLine(line, point([3, 0])); + t.equal( + result.properties.segmentIndex, + 2, + "segmentIndex is the last valid segment, not coords.length - 1" + ); + t.deepEqual(result.geometry.coordinates, [3, 0], "snapped to last vertex"); + + t.end(); +}); + +test("turf-nearest-point-on-line -- issue 3023 nearest point on intermediate vertex", (t) => { + // Case 2: nearest point lands exactly on an intermediate vertex. segmentIndex + // is the segment that *ends* at that vertex (i), no longer advanced to i + 1. + const line = lineString([ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + ]); + + const result = nearestPointOnLine(line, point([1, 0])); + t.equal( + result.properties.segmentIndex, + 0, + "segmentIndex is the segment ending at the vertex, not the next one" + ); + + t.end(); +}); + +test("turf-nearest-point-on-line -- issue 3023 atEndOfSegment / hasNextSegment on intermediate vertex", (t) => { + // Case 3: the helpers correctly identify a nearest point sitting on a shared + // intermediate vertex, and let callers rebuild the old "next segment" + // behaviour. + const line = lineString([ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + ]); + + const result = nearestPointOnLine(line, point([1, 0])); + + t.true(atEndOfSegment(result, line), "atEndOfSegment true on end vertex"); + t.true(hasNextSegment(result, line), "hasNextSegment true when more follow"); + t.false( + atEndOfLineString(result, line), + "atEndOfLineString false on intermediate vertex" + ); + + // Users can rebuild the legacy i + 1 behaviour from the helpers. + const legacyIndex = + result.properties.segmentIndex + + (atEndOfSegment(result, line) && hasNextSegment(result, line) ? 1 : 0); + t.equal(legacyIndex, 1, "legacy segmentIndex can be reconstructed"); + + t.end(); +}); + +test("turf-nearest-point-on-line -- issue 3023 atEndOfLineString on last vertex", (t) => { + // Case 4: nearest point on the very last vertex of the line. + const line = lineString([ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + ]); + + const result = nearestPointOnLine(line, point([3, 0])); + + t.true(atEndOfSegment(result, line), "atEndOfSegment true on last vertex"); + t.true( + atEndOfLineString(result, line), + "atEndOfLineString true on last vertex" + ); + t.false(hasNextSegment(result, line), "hasNextSegment false on last segment"); + + t.end(); +}); + +test("turf-nearest-point-on-line -- issue 3023 helpers on mid-segment point", (t) => { + // Case 5: a point falling in the middle of a segment (not on any vertex). + const line = lineString([ + [0, 0], + [10, 0], + [20, 0], + ]); + + const result = nearestPointOnLine(line, point([5, 1])); + + t.equal(result.properties.segmentIndex, 0, "found on first segment"); + t.false(atEndOfSegment(result, line), "atEndOfSegment false mid-segment"); + t.false( + atEndOfLineString(result, line), + "atEndOfLineString false mid-segment" + ); + t.true(hasNextSegment(result, line), "hasNextSegment true mid first segment"); + + t.end(); +}); + +test("turf-nearest-point-on-line -- issue 3023 LineString and MultiLineString parity", (t) => { + // Case 6: the same geometry split into a MultiLineString must yield a + // segmentIndex relative to its sub-LineString, reconcilable with the flat + // LineString segmentIndex via the accumulated segment offset. + const coords = [ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + [4, 0], + [5, 0], + ]; + const line = lineString(coords); + const multiLine = multiLineString([ + [coords[0], coords[1], coords[2]], + [coords[2], coords[3]], + [coords[3], coords[4], coords[5]], + ]); + // Segments before each sub-LineString: [P,Q,R]=2, [R,S]=1, [S,T,U]=2 + const lineStringOffset: Record = { 0: 0, 1: 2, 2: 3 }; + + const inputPoint = point([3.4, 1]); + const onLine = nearestPointOnLine(line, inputPoint); + const onMultiLine = nearestPointOnLine(multiLine, inputPoint); + + t.deepEqual( + onLine.geometry.coordinates, + onMultiLine.geometry.coordinates, + "both representations snap to the same coordinate" + ); + + const { lineStringIndex, segmentIndex } = onMultiLine.properties; + t.equal( + onLine.properties.segmentIndex, + lineStringOffset[lineStringIndex] + segmentIndex, + "LineString segmentIndex equals offset + MultiLineString segmentIndex" + ); + + t.end(); +}); + +test("turf-nearest-point-on-line -- issue 3023 helpers on MultiLineString geometry", (t) => { + // Helpers must resolve coords via lineStringIndex for MultiLineStrings, and + // work on bare geometries as well as Features. + const lines = multiLineString([ + [ + [0, 0], + [1, 0], + [2, 0], + ], + [ + [10, 10], + [11, 10], + [12, 10], + ], + ]); + + const result = nearestPointOnLine(lines, point([12, 10])); + + t.equal(result.properties.lineStringIndex, 1, "matched second LineString"); + t.equal( + result.properties.segmentIndex, + 1, + "segmentIndex relative to the matched LineString, no overflow" + ); + t.true( + atEndOfSegment(result, lines.geometry), + "atEndOfSegment works on bare MultiLineString geometry" + ); + t.true( + atEndOfLineString(result, lines), + "atEndOfLineString true on last vertex of sub-LineString" + ); + t.false( + hasNextSegment(result, lines), + "hasNextSegment false on last segment of sub-LineString" + ); + + t.end(); +}); diff --git a/packages/turf-nearest-point-on-line/test/out/multiLine1.geojson b/packages/turf-nearest-point-on-line/test/out/multiLine1.geojson index ad4ceee46..c593b2127 100644 --- a/packages/turf-nearest-point-on-line/test/out/multiLine1.geojson +++ b/packages/turf-nearest-point-on-line/test/out/multiLine1.geojson @@ -68,7 +68,7 @@ "type": "Feature", "properties": { "lineStringIndex": 1, - "segmentIndex": 21, + "segmentIndex": 20, "totalDistance": 9479.011715, "lineDistance": 4800.716022, "segmentDistance": 173.221741, @@ -76,7 +76,7 @@ "dist": 114.725451, "multiFeatureIndex": 1, "location": 9479.011715, - "index": 21, + "index": 20, "marker-color": "#F0F" }, "geometry": {