diff --git a/benchmark/point_in_polygon.dart b/benchmark/point_in_polygon.dart new file mode 100644 index 000000000..d75a643af --- /dev/null +++ b/benchmark/point_in_polygon.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_map/src/misc/point_in_polygon.dart'; +import 'package:logger/logger.dart'; + +class NoFilter extends LogFilter { + @override + bool shouldLog(LogEvent event) => true; +} + +typedef Result = ({ + String name, + Duration duration, +}); + +Future timedRun(String name, dynamic Function() body) async { + Logger().i('running $name...'); + final watch = Stopwatch()..start(); + await body(); + watch.stop(); + + return (name: name, duration: watch.elapsed); +} + +List makeCircle(int points, double radius, double phase) { + final slice = math.pi * 2 / (points - 1); + return List.generate(points, (i) { + // Note the modulo is only there to deal with floating point imprecision + // and ensure first == last. + final angle = slice * (i % (points - 1)) + phase; + return Offset(radius * math.cos(angle), radius * math.sin(angle)); + }, growable: false); +} + +// NOTE: to have a more prod like comparison, run with: +// $ dart compile exe benchmark/crs.dart && ./benchmark/crs.exe +// +// If you run in JIT mode, the resulting execution times will be a lot more similar. +Future main() async { + Logger.level = Level.all; + Logger.defaultFilter = NoFilter.new; + Logger.defaultPrinter = SimplePrinter.new; + + final results = []; + const N = 3000000; + + final circle = makeCircle(1000, 1, 0); + + results.add(await timedRun('In circle', () { + const point = math.Point(0, 0); + + bool yesPlease = true; + for (int i = 0; i < N; ++i) { + yesPlease = yesPlease && isPointInPolygon(point, circle); + } + + assert(yesPlease, 'should be in circle'); + return yesPlease; + })); + + results.add(await timedRun('Not in circle', () { + const point = math.Point(4, 4); + + bool noSir = false; + for (int i = 0; i < N; ++i) { + noSir = noSir || isPointInPolygon(point, circle); + } + + assert(!noSir, 'should not be in circle'); + return noSir; + })); + + Logger().i('Results:\n${results.map((r) => r.toString()).join('\n')}'); +} diff --git a/lib/src/geo/crs.dart b/lib/src/geo/crs.dart index 9b97aa1c5..63b5e1936 100644 --- a/lib/src/geo/crs.dart +++ b/lib/src/geo/crs.dart @@ -1,7 +1,7 @@ import 'dart:math' as math hide Point; import 'dart:math' show Point; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/misc/bounds.dart'; import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; import 'package:proj4dart/proj4dart.dart' as proj4; diff --git a/lib/src/layer/polygon_layer/painter.dart b/lib/src/layer/polygon_layer/painter.dart index 315fe0402..1952ee8ca 100644 --- a/lib/src/layer/polygon_layer/painter.dart +++ b/lib/src/layer/polygon_layer/painter.dart @@ -39,42 +39,41 @@ base class _PolygonPainter required LatLng coordinate, }) { final polygon = projectedPolygon.polygon; - - if (!polygon.boundingBox.contains(coordinate)) return false; + if (!polygon.boundingBox.contains(coordinate)) { + return false; + } final projectedCoords = getOffsetsXY( camera: camera, origin: hitTestCameraOrigin, points: projectedPolygon.points, - ).toList(); + ); if (projectedCoords.first != projectedCoords.last) { projectedCoords.add(projectedCoords.first); } + final isInPolygon = isPointInPolygon(point, projectedCoords); final hasHoles = projectedPolygon.holePoints.isNotEmpty; - late final List> projectedHoleCoords; - if (hasHoles) { - projectedHoleCoords = projectedPolygon.holePoints - .map( - (points) => getOffsetsXY( + final isInHole = hasHoles && + () { + for (final points in projectedPolygon.holePoints) { + final projectedHoleCoords = getOffsetsXY( camera: camera, origin: hitTestCameraOrigin, points: points, - ).toList(), - ) - .toList(); + ); - if (projectedHoleCoords.firstOrNull != projectedHoleCoords.lastOrNull) { - projectedHoleCoords.add(projectedHoleCoords.first); - } - } + if (projectedHoleCoords.first != projectedHoleCoords.last) { + projectedHoleCoords.add(projectedHoleCoords.first); + } - final isInPolygon = _isPointInPolygon(point, projectedCoords); - final isInHole = hasHoles && - projectedHoleCoords - .map((c) => _isPointInPolygon(point, c)) - .any((e) => e); + if (isPointInPolygon(point, projectedHoleCoords)) { + return true; + } + } + return false; + }(); // Second check handles case where polygon outline intersects a hole, // ensuring that the hit matches with the visual representation @@ -361,24 +360,6 @@ base class _PolygonPainter ); } - /// Checks whether point [p] is within the specified closed [polygon] - /// - /// Uses the even-odd algorithm. - static bool _isPointInPolygon(math.Point p, List polygon) { - bool isInPolygon = false; - - for (int i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - if ((((polygon[i].dy <= p.y) && (p.y < polygon[j].dy)) || - ((polygon[j].dy <= p.y) && (p.y < polygon[i].dy))) && - (p.x < - (polygon[j].dx - polygon[i].dx) * - (p.y - polygon[i].dy) / - (polygon[j].dy - polygon[i].dy) + - polygon[i].dx)) isInPolygon = !isInPolygon; - } - return isInPolygon; - } - @override bool shouldRepaint(_PolygonPainter oldDelegate) => polygons != oldDelegate.polygons || diff --git a/lib/src/layer/polygon_layer/polygon_layer.dart b/lib/src/layer/polygon_layer/polygon_layer.dart index ce20d0c99..ac959ed10 100644 --- a/lib/src/layer/polygon_layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer/polygon_layer.dart @@ -9,6 +9,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart'; import 'package:flutter_map/src/layer/shared/line_patterns/pixel_hiker.dart'; import 'package:flutter_map/src/misc/offsets.dart'; +import 'package:flutter_map/src/misc/point_in_polygon.dart'; import 'package:flutter_map/src/misc/simplify.dart'; import 'package:latlong2/latlong.dart' hide Path; import 'package:polylabel/polylabel.dart'; diff --git a/lib/src/misc/offsets.dart b/lib/src/misc/offsets.dart index 1db896931..c22c216de 100644 --- a/lib/src/misc/offsets.dart +++ b/lib/src/misc/offsets.dart @@ -25,7 +25,7 @@ List getOffsets(MapCamera camera, Offset origin, List points) { // Optimization: monomorphize the Epsg3857-case to avoid the virtual function overhead. if (crs case final Epsg3857 epsg3857) { - final v = List.filled(len, Offset.zero); + final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { final (x, y) = epsg3857.latLngToXY(points[i], zoomScale); v[i] = Offset(x + ox, y + oy); @@ -33,7 +33,7 @@ List getOffsets(MapCamera camera, Offset origin, List points) { return v; } - final v = List.filled(len, Offset.zero); + final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { final (x, y) = crs.latLngToXY(points[i], zoomScale); v[i] = Offset(x + ox, y + oy); @@ -63,7 +63,7 @@ List getOffsetsXY({ // Optimization: monomorphize the CrsWithStaticTransformation-case to avoid // the virtual function overhead. if (crs case final CrsWithStaticTransformation crs) { - final v = List.filled(len, Offset.zero); + final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { final p = realPoints.elementAt(i); final (x, y) = crs.transform(p.x, p.y, zoomScale); @@ -72,7 +72,7 @@ List getOffsetsXY({ return v; } - final v = List.filled(len, Offset.zero); + final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { final p = realPoints.elementAt(i); final (x, y) = crs.transform(p.x, p.y, zoomScale); diff --git a/lib/src/misc/point_in_polygon.dart b/lib/src/misc/point_in_polygon.dart new file mode 100644 index 000000000..9f0192da0 --- /dev/null +++ b/lib/src/misc/point_in_polygon.dart @@ -0,0 +1,29 @@ +import 'dart:math' as math; +import 'dart:ui'; + +/// Checks whether point [p] is within the specified closed [polygon] +/// +/// Uses the even-odd algorithm and requires closed loop polygons, i.e. +/// `polygon.first == polygon.last`. +bool isPointInPolygon(math.Point p, List polygon) { + final len = polygon.length; + assert(len >= 3, 'not a polygon'); + assert(polygon.first == polygon.last, 'polygon not closed'); + final double px = p.x.toDouble(); + final double py = p.y.toDouble(); + + bool isInPolygon = false; + for (int i = 0, j = len - 1; i < len; j = i++) { + final double poIx = polygon[i].dx; + final double poIy = polygon[i].dy; + + final double poJx = polygon[j].dx; + final double poJy = polygon[j].dy; + + if ((((poIy <= py) && (py < poJy)) || ((poJy <= py) && (py < poIy))) && + (px < (poJx - poIx) * (py - poIy) / (poJy - poIy) + poIx)) { + isInPolygon = !isInPolygon; + } + } + return isInPolygon; +} diff --git a/test/misc/point_in_polygon_test.dart b/test/misc/point_in_polygon_test.dart new file mode 100644 index 000000000..b9f697125 --- /dev/null +++ b/test/misc/point_in_polygon_test.dart @@ -0,0 +1,41 @@ +import 'dart:math' as math; + +import 'package:flutter_map/src/misc/point_in_polygon.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List makeCircle(int points, double radius, double phase) { + final slice = math.pi * 2 / (points - 1); + return List.generate(points, (i) { + // Note the modulo is only there to deal with floating point imprecision + // and ensure first == last. + final angle = slice * (i % (points - 1)) + phase; + return Offset(radius * math.cos(angle), radius * math.sin(angle)); + }, growable: false); +} + +void main() { + test('Smoke test for points in and out of polygons', () { + final circle = makeCircle(100, 1, 0); + + // Inside points + for (final point in makeCircle(32, 0.8, 0.0001)) { + final p = math.Point(point.dx, point.dy); + expect(isPointInPolygon(p, circle), isTrue); + } + + // Edge-case: check origin + expect(isPointInPolygon(const math.Point(0, 0), circle), isTrue); + + // Outside points: small radius + for (final point in makeCircle(32, 1.1, 0.0001)) { + final p = math.Point(point.dx, point.dy); + expect(isPointInPolygon(p, circle), isFalse); + } + + // Outside points: large radius + for (final point in makeCircle(32, 100000, 0.0001)) { + final p = math.Point(point.dx, point.dy); + expect(isPointInPolygon(p, circle), isFalse); + } + }); +}