From 57c3616353788646e9da4c458a72325a22ffd679 Mon Sep 17 00:00:00 2001 From: Lukas Himsel Date: Wed, 20 Apr 2022 08:26:32 +0200 Subject: [PATCH] Implement `nearestPointOn(Multi)Line` and `internal` intersects helper (Attempt 2) (#87) * Implement `nearestPointOn(Multi)Line` (#86) Co-authored-by: Levente Morva * localIndex and index * add documentation * Fix `globalIndex` behaviour Co-authored-by: Levente Morva * fix test, according to new globalIndex behaviour * add intersection test Co-authored-by: Levente Morva --- README.md | 2 +- lib/nearest_point_on_line.dart | 3 + lib/src/intersection.dart | 41 +++ lib/src/nearest_point_on_line.dart | 216 +++++++++++++ lib/turf.dart | 1 + test/components/intersection_test.dart | 31 ++ .../nearest_point_on_line_test.dart | 302 ++++++++++++++++++ 7 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 lib/nearest_point_on_line.dart create mode 100644 lib/src/intersection.dart create mode 100644 lib/src/nearest_point_on_line.dart create mode 100644 test/components/intersection_test.dart create mode 100644 test/components/nearest_point_on_line_test.dart diff --git a/README.md b/README.md index ad34295..3e40625 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Any new benchmarks must be named `*_benchmark.dart` and reside in the - [ ] lineSliceAlong - [ ] lineSplit - [ ] mask -- [ ] nearestPointOnLine +- [x] [nearestPointOnLine](https://github.com/dartclub/turf_dart/blob/master/lib/nearest_point_on_line.dart) - [ ] sector - [ ] shortestPath - [ ] unkinkPolygon diff --git a/lib/nearest_point_on_line.dart b/lib/nearest_point_on_line.dart new file mode 100644 index 0000000..71d7ebc --- /dev/null +++ b/lib/nearest_point_on_line.dart @@ -0,0 +1,3 @@ +library turf_nearest_point_on_line; + +export 'src/nearest_point_on_line.dart'; diff --git a/lib/src/intersection.dart b/lib/src/intersection.dart new file mode 100644 index 0000000..72ecf35 --- /dev/null +++ b/lib/src/intersection.dart @@ -0,0 +1,41 @@ +import 'geojson.dart'; + +Point? intersects(LineString line1, LineString line2) { + if (line1.coordinates.length != 2) { + throw Exception('line1 must only contain 2 coordinates'); + } + + if (line2.coordinates.length != 2) { + throw Exception('line2 must only contain 2 coordinates'); + } + + final x1 = line1.coordinates[0][0]!; + final y1 = line1.coordinates[0][1]!; + final x2 = line1.coordinates[1][0]!; + final y2 = line1.coordinates[1][1]!; + final x3 = line2.coordinates[0][0]!; + final y3 = line2.coordinates[0][1]!; + final x4 = line2.coordinates[1][0]!; + final y4 = line2.coordinates[1][1]!; + + final denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1); + + if (denom == 0) { + return null; + } + + final numeA = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3); + final numeB = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3); + + final uA = numeA / denom; + final uB = numeB / denom; + + if (uA >= 0 && uA <= 1 && uB >= 0 && uB <= 1) { + final x = x1 + uA * (x2 - x1); + final y = y1 + uA * (y2 - y1); + + return Point(coordinates: Position.named(lng: x, lat: y)); + } + + return null; +} diff --git a/lib/src/nearest_point_on_line.dart b/lib/src/nearest_point_on_line.dart new file mode 100644 index 0000000..72e924a --- /dev/null +++ b/lib/src/nearest_point_on_line.dart @@ -0,0 +1,216 @@ +import 'dart:math'; + +import 'bearing.dart'; +import 'destination.dart'; +import 'distance.dart'; +import 'geojson.dart'; +import 'helpers.dart'; +import 'intersection.dart'; + +class _Nearest { + final Point point; + final num distance; + final int index; + final num location; + + _Nearest({ + required this.point, + required this.distance, + required this.index, + required this.location, + }); + + Feature toFeature() { + return Feature( + geometry: point, + properties: { + 'dist': distance, + 'index': index, + 'location': location, + }, + ); + } +} + +class _NearestMulti extends _Nearest { + final int line; + final int localIndex; + + _NearestMulti({ + required Point point, + required num distance, + required int index, + required this.localIndex, + required num location, + required this.line, + }) : super( + point: point, + distance: distance, + index: index, + location: location, + ); + + @override + Feature toFeature() { + return Feature( + geometry: point, + properties: { + 'dist': super.distance, + 'line': line, + 'index': super.index, + 'localIndex': localIndex, + 'location': super.location, + }, + ); + } +} + +_Nearest _nearestPointOnLine( + LineString line, + Point point, [ + Unit unit = Unit.kilometers, +]) { + _Nearest? nearest; + + num length = 0; + + for (var i = 0; i < line.coordinates.length - 1; ++i) { + final startCoordinates = line.coordinates[i]; + final stopCoordinates = line.coordinates[i + 1]; + + final startPoint = Point(coordinates: startCoordinates); + final stopPoint = Point(coordinates: stopCoordinates); + + final sectionLength = distance(startPoint, stopPoint, unit); + + final start = _Nearest( + point: startPoint, + distance: distance(point, startPoint, unit), + index: i, + location: length, + ); + + final stop = _Nearest( + point: stopPoint, + distance: distance(point, stopPoint, unit), + index: i + 1, + location: length + sectionLength, + ); + + final heightDistance = max(start.distance, stop.distance); + final direction = bearing(startPoint, stopPoint); + + final perpendicular1 = destination( + point, + heightDistance, + direction + 90, + unit, + ); + + final perpendicular2 = destination( + point, + heightDistance, + direction - 90, + unit, + ); + + final intersectionPoint = intersects( + LineString.fromPoints(points: [perpendicular1, perpendicular2]), + LineString.fromPoints(points: [startPoint, stopPoint]), + ); + + _Nearest? intersection; + + if (intersectionPoint != null) { + intersection = _Nearest( + point: intersectionPoint, + distance: distance(point, intersectionPoint, unit), + index: i, + location: length + distance(startPoint, intersectionPoint, unit), + ); + } + + if (nearest == null || start.distance < nearest.distance) { + nearest = start; + } + + if (stop.distance < nearest.distance) { + nearest = stop; + } + + if (intersection != null && intersection.distance < nearest.distance) { + nearest = intersection; + } + + length += sectionLength; + } + + /// A `LineString` is guaranteed to have at least two points and thus a + /// nearest point has to exist. + + return nearest!; +} + +_NearestMulti? _nearestPointOnMultiLine( + MultiLineString lines, + Point point, [ + Unit unit = Unit.kilometers, +]) { + _NearestMulti? nearest; + + var globalIndex = 0; + + for (var i = 0; i < lines.coordinates.length; ++i) { + final line = LineString(coordinates: lines.coordinates[i]); + + final candidate = _nearestPointOnLine(line, point); + + if (nearest == null || candidate.distance < nearest.distance) { + nearest = _NearestMulti( + point: candidate.point, + distance: candidate.distance, + index: globalIndex + candidate.index, + localIndex: candidate.index, + location: candidate.location, + line: i, + ); + } + + globalIndex += line.coordinates.length; + } + + return nearest; +} + +/// Takes a [Point] and a [LineString] and calculates the closest Point on the [LineString]. +/// ```dart +/// var line = LineString( +/// coordinates: [ +/// Position.of([-77.031669, 38.878605]), +/// Position.of([-77.029609, 38.881946]), +/// Position.of([-77.020339, 38.884084]), +/// Position.of([-77.025661, 38.885821]), +/// Position.of([-77.021884, 38.889563]), +/// Position.of([-77.019824, 38.892368)] +/// ]); +/// var pt = Point(coordinates: Position(lat: -77.037076, lng: 38.884017)); +/// +/// var snapped = nearestPointOnLine(line, pt, Unit.miles); +/// ``` +/// +Feature nearestPointOnLine( + LineString line, + Point point, [ + Unit unit = Unit.kilometers, +]) { + return _nearestPointOnLine(line, point, unit).toFeature(); +} + +/// Takes a [Point] and a [MultiLineString] and calculates the closest Point on the [MultiLineString]. +Feature? nearestPointOnMultiLine( + MultiLineString lines, + Point point, [ + Unit unit = Unit.kilometers, +]) { + return _nearestPointOnMultiLine(lines, point, unit)?.toFeature(); +} diff --git a/lib/turf.dart b/lib/turf.dart index 380b60e..9500298 100644 --- a/lib/turf.dart +++ b/lib/turf.dart @@ -7,3 +7,4 @@ export 'src/geojson.dart'; export 'src/midpoint.dart'; export 'src/helpers.dart'; export 'src/nearest_point.dart'; +export 'src/nearest_point_on_line.dart'; diff --git a/test/components/intersection_test.dart b/test/components/intersection_test.dart new file mode 100644 index 0000000..13bc630 --- /dev/null +++ b/test/components/intersection_test.dart @@ -0,0 +1,31 @@ +import 'package:test/test.dart'; +import 'package:turf/helpers.dart'; +import 'package:turf/src/intersection.dart'; + +final l1 = LineString(coordinates: [ + Position(0, 0), + Position(2, 2), +]); + +final l2 = LineString(coordinates: [ + Position(2, 0), + Position(0, 2), +]); + +final l3 = LineString(coordinates: [ + Position(2, 2), + Position(2, 0), +]); + +final l4 = LineString(coordinates: [ + Position(0, 0), + Position(0, 2), +]); + +main() { + test('test intersects()', () { + expect(intersects(l1, l2)?.coordinates, Position(1, 1)); + expect(intersects(l1, l3)?.coordinates, Position(2, 2)); + expect(intersects(l3, l4), null); + }); +} diff --git a/test/components/nearest_point_on_line_test.dart b/test/components/nearest_point_on_line_test.dart new file mode 100644 index 0000000..84079e2 --- /dev/null +++ b/test/components/nearest_point_on_line_test.dart @@ -0,0 +1,302 @@ +import 'package:test/test.dart'; +import 'package:turf/distance.dart'; +import 'package:turf/helpers.dart'; +import 'package:turf/nearest_point_on_line.dart'; + +main() { + test('nearest_point_on_line -- start point', () { + final start = Point(coordinates: Position.of([-122.457175, 37.720033])); + final end = Point(coordinates: Position.of([-122.457175, 37.718242])); + + final line = LineString.fromPoints(points: [start, end]); + + final snapped = nearestPointOnLine(line, start); + + expect(snapped.geometry, start); + expect(snapped.properties!['dist'], 0); + }); + + test('nearest_point_on_line -- end point', () { + final start = Point(coordinates: Position.of([-122.457175, 37.720033])); + final end = Point(coordinates: Position.of([-122.457175, 37.718242])); + + final line = LineString.fromPoints(points: [start, end]); + + final snapped = nearestPointOnLine(line, end); + + expect(snapped.geometry, end); + expect(snapped.properties!['dist'], 0); + }); + + test('nearest_point_on_line -- behind start point', () { + final start = Point(coordinates: Position.of([-122.457175, 37.720033])); + final end = Point(coordinates: Position.of([-122.457175, 37.718242])); + + final line = LineString.fromPoints(points: [start, end]); + + final points = [ + Point(coordinates: Position.of([-122.457175, 37.720093])), + Point(coordinates: Position.of([-122.457175, 37.820093])), + Point(coordinates: Position.of([-122.457165, 37.720093])), + Point(coordinates: Position.of([-122.455165, 37.720093])), + ]; + + for (final point in points) { + expect(nearestPointOnLine(line, point).geometry, start); + } + }); + + test('nearest_point_on_line -- in front of last point', () { + final start = Point(coordinates: Position.of([-122.456161, 37.721259])); + final middle = Point(coordinates: Position.of([-122.457175, 37.720033])); + final end = Point(coordinates: Position.of([-122.457175, 37.718242])); + + final line = LineString.fromPoints(points: [start, middle, end]); + + final points = [ + Point(coordinates: Position.of([-122.45696, 37.71814])), + Point(coordinates: Position.of([-122.457363, 37.718132])), + Point(coordinates: Position.of([-122.457309, 37.717979])), + Point(coordinates: Position.of([-122.45718, 37.717045])), + ]; + + for (final point in points) { + expect(nearestPointOnLine(line, point).geometry, end); + } + }); + + test('nearest_point_on_line -- on joints', () { + final lines = [ + LineString.fromPoints( + points: [ + Point(coordinates: Position.of([-122.456161, 37.721259])), + Point(coordinates: Position.of([-122.457175, 37.720033])), + Point(coordinates: Position.of([-122.457175, 37.718242])), + ], + ), + LineString.fromPoints( + points: [ + Point(coordinates: Position.of([26.279296, 31.728167])), + Point(coordinates: Position.of([21.796875, 32.694865])), + Point(coordinates: Position.of([18.808593, 29.993002])), + Point(coordinates: Position.of([12.919921, 33.137551])), + Point(coordinates: Position.of([10.195312, 35.603718])), + Point(coordinates: Position.of([4.921875, 36.527294])), + Point(coordinates: Position.of([-1.669921, 36.527294])), + Point(coordinates: Position.of([-5.449218, 34.741612])), + Point(coordinates: Position.of([-8.789062, 32.990235])), + ], + ), + LineString.fromPoints( + points: [ + Point(coordinates: Position.of([-0.109198, 51.522042])), + Point(coordinates: Position.of([-0.10923, 51.521942])), + Point(coordinates: Position.of([-0.109165, 51.521862])), + Point(coordinates: Position.of([-0.109047, 51.521775])), + Point(coordinates: Position.of([-0.108865, 51.521601])), + Point(coordinates: Position.of([-0.108747, 51.521381])), + Point(coordinates: Position.of([-0.108554, 51.520687])), + Point(coordinates: Position.of([-0.108436, 51.520279])), + Point(coordinates: Position.of([-0.108393, 51.519952])), + Point(coordinates: Position.of([-0.108178, 51.519578])), + Point(coordinates: Position.of([-0.108146, 51.519285])), + Point(coordinates: Position.of([-0.107899, 51.518624])), + Point(coordinates: Position.of([-0.107599, 51.517782])), + ], + ), + ]; + + for (final line in lines) { + for (final position in line.coordinates) { + final point = Point(coordinates: position); + + expect(nearestPointOnLine(line, point).geometry, point); + } + } + }); + + test('nearest_point_on_line -- along the line', () { + final line = LineString.fromPoints( + points: [ + Point(coordinates: Position.of([-0.109198, 51.522042])), + Point(coordinates: Position.of([-0.10923, 51.521942])), + Point(coordinates: Position.of([-0.109165, 51.521862])), + Point(coordinates: Position.of([-0.109047, 51.521775])), + Point(coordinates: Position.of([-0.108865, 51.521601])), + Point(coordinates: Position.of([-0.108747, 51.521381])), + Point(coordinates: Position.of([-0.108554, 51.520687])), + Point(coordinates: Position.of([-0.108436, 51.520279])), + Point(coordinates: Position.of([-0.108393, 51.519952])), + Point(coordinates: Position.of([-0.108178, 51.519578])), + Point(coordinates: Position.of([-0.108146, 51.519285])), + Point(coordinates: Position.of([-0.107899, 51.518624])), + Point(coordinates: Position.of([-0.107599, 51.517782])), + ], + ); + + final points = [ + Point( + coordinates: Position.of([-0.109198, 51.522042]), + ), + Point( + coordinates: Position.of([-0.10892694586958439, 51.52166022315509]), + ), + Point( + coordinates: Position.of([-0.10870869056086806, 51.52124324652249]), + ), + Point( + coordinates: Position.of([-0.10858746428471407, 51.520807334251415]), + ), + Point( + coordinates: Position.of([-0.10846283773612979, 51.52037179553692]), + ), + Point( + coordinates: Position.of([-0.10838216818271691, 51.51993315783233]), + ), + Point( + coordinates: Position.of([-0.1081708961571415, 51.51951295576514]), + ), + Point( + coordinates: Position.of([-0.10806814357223703, 51.5190766495002]), + ), + Point( + coordinates: Position.of([-0.10790712893372725, 51.51864575426176]), + ), + Point( + coordinates: Position.of([-0.10775288313545159, 51.518213902651325]), + ), + ]; + + for (final point in points) { + final snapped = nearestPointOnLine(line, point); + final shift = distance(point, snapped.geometry!, Unit.centimeters); + + expect(shift < 1, isTrue); + } + }); + + test('nearest_point_on_line -- on sides of line', () { + final start = Point(coordinates: Position.of([-122.456161, 37.721259])); + final end = Point(coordinates: Position.of([-122.457175, 37.718242])); + + final line = LineString.fromPoints(points: [start, end]); + + final points = [ + Point(coordinates: Position.of([-122.457025, 37.71881])), + Point(coordinates: Position.of([-122.457336, 37.719235])), + Point(coordinates: Position.of([-122.456864, 37.72027])), + Point(coordinates: Position.of([-122.45652, 37.720635])), + ]; + + for (final point in points) { + final snapped = nearestPointOnLine(line, point); + + expect(snapped.geometry, isNot(start)); + expect(snapped.geometry, isNot(end)); + } + }); + + test('nearest_point_on_line -- distance and index', () { + final line = LineString.fromPoints( + points: [ + Point(coordinates: Position.of([-92.090492, 41.102897])), + Point(coordinates: Position.of([-92.191085, 41.079868])), + Point(coordinates: Position.of([-92.228507, 41.056055])), + Point(coordinates: Position.of([-92.237091, 41.008143])), + Point(coordinates: Position.of([-92.225761, 40.966937])), + Point(coordinates: Position.of([-92.15023, 40.936858])), + Point(coordinates: Position.of([-92.112464, 40.977565])), + Point(coordinates: Position.of([-92.062683, 41.034564])), + Point(coordinates: Position.of([-92.100791, 41.040002])), + ], + ); + + final point = Point(coordinates: Position.of([-92.110576, 41.040649])); + final target = Point(coordinates: Position.of([-92.100791, 41.040002])); + + final snapped = nearestPointOnLine(line, point); + + expect(snapped.geometry, target); + + final index = snapped.properties!['index'] as int; + final distance = snapped.properties!['dist'] as num; + + expect(index, 8); + expect(distance.toStringAsFixed(6), '0.823802'); + }); + + test('nearest_point_on_line -- empty multi-line', () { + final multiLine = MultiLineString(coordinates: []); + + final point = Point(coordinates: Position.of([-92.110576, 41.040649])); + + final snapped = nearestPointOnMultiLine(multiLine, point); + + expect(snapped, isNull); + }); + + test('nearest_point_on_line -- distance, line, and index', () { + final multiLine = MultiLineString.fromLineStrings( + lineStrings: [ + LineString.fromPoints( + points: [ + Point(coordinates: Position.of([-92.090492, 41.102897])), + Point(coordinates: Position.of([-92.191085, 41.079868])), + Point(coordinates: Position.of([-92.228507, 41.056055])), + Point(coordinates: Position.of([-92.237091, 41.008143])), + Point(coordinates: Position.of([-92.225761, 40.966937])), + Point(coordinates: Position.of([-92.15023, 40.936858])), + Point(coordinates: Position.of([-92.112464, 40.977565])), + Point(coordinates: Position.of([-92.062683, 41.034564])), + Point(coordinates: Position.of([-92.100791, 41.040002])), + ], + ), + LineString.fromPoints( + points: [ + Point(coordinates: Position.of([-92.141304, 41.124107])), + Point(coordinates: Position.of([-92.020797, 41.108329])), + Point(coordinates: Position.of([-91.973762, 41.019023])), + Point(coordinates: Position.of([-92.041740, 40.944120])), + Point(coordinates: Position.of([-92.151260, 40.928299])), + Point(coordinates: Position.of([-92.198295, 40.941008])), + Point(coordinates: Position.of([-92.199668, 41.012547])), + Point(coordinates: Position.of([-92.115413, 41.041633])), + Point(coordinates: Position.of([-92.143020, 41.076504])), + ], + ), + LineString.fromPoints( + points: [ + Point(coordinates: Position.of([-92.066116, 41.079092])), + Point(coordinates: Position.of([-92.028007, 41.045957])), + Point(coordinates: Position.of([-92.040023, 40.981453])), + Point(coordinates: Position.of([-92.114181, 40.951640])), + Point(coordinates: Position.of([-92.176666, 40.968752])), + Point(coordinates: Position.of([-92.210655, 40.997002])), + Point(coordinates: Position.of([-92.209968, 41.048547])), + Point(coordinates: Position.of([-92.158126, 41.071327])), + Point(coordinates: Position.of([-92.102508, 41.082197])), + ], + ), + ], + ); + + final point = Point(coordinates: Position.of([-92.110576, 41.040649])); + final target = Point(coordinates: Position.of([-92.115413, 41.041633])); + + final snapped = nearestPointOnMultiLine(multiLine, point); + + expect(snapped, isNotNull); + + expect(snapped!.geometry, target); + + final line = snapped.properties!['line'] as int; + final localIndex = snapped.properties!['localIndex'] as int; + final globalIndex = snapped.properties!['index'] as int; + final distance = snapped.properties!['dist'] as num; + + expect(line, 1); + expect(localIndex, 7); + expect(globalIndex, 16); + expect(distance.toStringAsFixed(6), '0.420164'); + }); +}