diff --git a/example/polylabel_example.dart b/example/polylabel_example.dart index 77ffb88..b237ab7 100644 --- a/example/polylabel_example.dart +++ b/example/polylabel_example.dart @@ -4,7 +4,13 @@ import 'package:polylabel/polylabel.dart'; void main() { final polygon = [ - [Point(0, 0), Point(1, 0), Point(1, 1), Point(0, 1), Point(0, 0)] + [ + Point(0, 0), + Point(1, 0), + Point(1, 1), + Point(0, 1), + Point(0, 0) + ] ]; final result = polylabel(polygon); print(result); // PolylabelResult(Point(0.5, 0.5), distance: 0.5) diff --git a/lib/src/data.dart b/lib/src/data.dart index c941a9a..eecd186 100644 --- a/lib/src/data.dart +++ b/lib/src/data.dart @@ -1,46 +1,52 @@ import 'dart:math' show min, sqrt, sqrt2, Point; -typedef Polygon = List>; +typedef Polygon = List>>; class PolylabelResult { - final Point point; - final num distance; + final Point point; + final double distance; const PolylabelResult(this.point, this.distance); @override - String toString() => '$runtimeType($point, distance: $distance)'; + String toString() => 'PolylabelResult($point, distance: $distance)'; } class Cell { - final Point c; // cell center - final num h; // half the cell size - final num d; // distance from cell center to polygon - late num max; // max distance to polygon within a cell + // Cell center (x, y). + final double x; + final double y; - Cell(this.c, this.h, Polygon polygon) : d = pointToPolygonDist(c, polygon) { - max = d + h * sqrt2; + final double h; // half the cell size + final double d; // distance from cell center to polygon + + late final double max; + + Cell(this.x, this.y, this.h, Polygon polygon) + : d = pointToPolygonDist(x, y, polygon) { + max = d + h * sqrt2; // max distance to polygon within a cell } } /// Signed distance from point to polygon outline (negative if point is outside) -num pointToPolygonDist(Point point, Polygon polygon) { +double pointToPolygonDist(final double x, final double y, Polygon polygon) { bool inside = false; - num minDistSq = double.infinity; + double minDistSq = double.infinity; - for (var k = 0; k < polygon.length; k++) { + for (int k = 0; k < polygon.length; k++) { final ring = polygon[k]; + final len = ring.length; - for (var i = 0, len = ring.length, j = len - 1; i < len; j = i++) { + for (int i = 0, j = len - 1; i < len; j = i++) { final a = ring[i]; final b = ring[j]; - if ((a.y > point.y != b.y > point.y) && - (point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x)) { + if ((a.y > y != b.y > y) && + (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x)) { inside = !inside; } - minDistSq = min(minDistSq, getSegDistSq(point, a, b)); + minDistSq = min(minDistSq, getSegDistSq(x, y, a, b)); } } @@ -48,14 +54,19 @@ num pointToPolygonDist(Point point, Polygon polygon) { } /// Get squared distance from a point to a segment -num getSegDistSq(Point p, Point a, Point b) { - num x = a.x; - num y = a.y; - num dx = b.x - x; - num dy = b.y - y; +double getSegDistSq( + final double px, + final double py, + Point a, + Point b, +) { + double x = a.x; + double y = a.y; + double dx = b.x - x; + double dy = b.y - y; if (dx != 0 || dy != 0) { - final t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy); + final t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy); if (t > 1) { x = b.x; @@ -66,8 +77,8 @@ num getSegDistSq(Point p, Point a, Point b) { } } - dx = p.x - x; - dy = p.y - y; + dx = px - x; + dy = py - y; return dx * dx + dy * dy; } diff --git a/lib/src/polylabel_base.dart b/lib/src/polylabel_base.dart index db21c41..a0d0dbc 100644 --- a/lib/src/polylabel_base.dart +++ b/lib/src/polylabel_base.dart @@ -4,90 +4,104 @@ import 'package:collection/collection.dart'; import 'data.dart'; /// Finds the polygon pole of inaccessibility. +/// +/// Precision is given in "polygon point" units. Generally, a larger number +/// means a looser acceptance threshold and thus a lower precision. PolylabelResult polylabel( - List> polygon, { + Polygon polygon, { double precision = 1.0, bool debug = false, }) { - // find the bounding box of the outer ring - num minX = 0, minY = 0, maxX = 0, maxY = 0; - for (var i = 0; i < polygon[0].length; i++) { - var p = polygon[0][i]; - if (i == 0 || p.x < minX) minX = p.x; - if (i == 0 || p.y < minY) minY = p.y; - if (i == 0 || p.x > maxX) maxX = p.x; - if (i == 0 || p.y > maxY) maxY = p.y; + if (polygon.isEmpty || polygon[0].isEmpty) { + return _empty; } - num width = maxX - minX; - num height = maxY - minY; - num cellSize = min(width, height); - num h = cellSize / 2; + final ring = polygon[0]; + + // find the bounding box of the outer ring + double minX = ring[0].x, minY = ring[0].y, maxX = ring[0].x, maxY = ring[0].y; + + for (int i = 0; i < ring.length; i++) { + final p = ring[i]; + + if (p.x < minX) minX = p.x; + if (p.y < minY) minY = p.y; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + } + final width = maxX - minX; + final height = maxY - minY; + final cellSize = min(width, height); if (cellSize == 0) { - return PolylabelResult(Point(minX, minY), 0); + return _empty; } // a priority queue of cells in order of their "potential" (max distance to polygon) final cellQueue = PriorityQueue((a, b) => b.max.compareTo(a.max)); // cover polygon with initial cells + final h = cellSize / 2; for (var x = minX; x < maxX; x += cellSize) { for (var y = minY; y < maxY; y += cellSize) { - cellQueue.add(Cell(Point(x + h, y + h), h, polygon)); + final dx = x + h; + final dy = y + h; + cellQueue.add(Cell(dx, dy, h, polygon)); } } // take centroid as the first best guess - var bestCell = _getCentroidCell(polygon); + Cell bestCell = _getCentroidCell(polygon); // second guess: bounding box centroid - var bboxCell = Cell(Point(minX + width / 2, minY + height / 2), 0, polygon); - if (bboxCell.d > bestCell.d) bestCell = bboxCell; - - int numProbes = cellQueue.length; + final bboxCell = Cell(minX + width / 2, minY + height / 2, 0, polygon); + if (bboxCell.d > bestCell.d) { + bestCell = bboxCell; + } while (cellQueue.isNotEmpty) { // pick the most promising cell from the queue final cell = cellQueue.removeFirst(); - // update the best cell if we found a better one + // update the best cell if we found a better one (i.e maximizing the distance). if (cell.d > bestCell.d) { bestCell = cell; if (debug) { print( - 'found best ${(1e4 * cell.d).round() / 1e4} after $numProbes probes', + 'found best ${(1e4 * cell.d).round() / 1e4} after ${cellQueue.length} probes', ); } } // do not drill down further if there's no chance of a better solution - if (cell.max - bestCell.d <= precision) continue; + if (cell.max - bestCell.d <= precision) { + continue; + } // split the cell into four cells - h = cell.h / 2; - cellQueue.add(Cell(Point(cell.c.x - h, cell.c.y - h), h, polygon)); - cellQueue.add(Cell(Point(cell.c.x + h, cell.c.y - h), h, polygon)); - cellQueue.add(Cell(Point(cell.c.x - h, cell.c.y + h), h, polygon)); - cellQueue.add(Cell(Point(cell.c.x + h, cell.c.y + h), h, polygon)); - numProbes += 4; + final h = cell.h / 2; + cellQueue.add(Cell(cell.x - h, cell.y - h, h, polygon)); + cellQueue.add(Cell(cell.x + h, cell.y - h, h, polygon)); + cellQueue.add(Cell(cell.x - h, cell.y + h, h, polygon)); + cellQueue.add(Cell(cell.x + h, cell.y + h, h, polygon)); } if (debug) { print('best distance: ${bestCell.d}'); } - return PolylabelResult(bestCell.c, bestCell.d); + return PolylabelResult(Point(bestCell.x, bestCell.y), bestCell.d); } /// Get polygon centroid Cell _getCentroidCell(Polygon polygon) { - num area = 0; - num x = 0; - num y = 0; + double area = 0; + double x = 0; + double y = 0; final ring = polygon[0]; + final len = ring.length; - for (var i = 0, len = ring.length, j = len - 1; i < len; j = i++) { + for (int i = 0, j = len - 1; i < len; j = i++) { final a = ring[i]; final b = ring[j]; final f = a.x * b.y - b.x * a.y; @@ -95,6 +109,11 @@ Cell _getCentroidCell(Polygon polygon) { y += (a.y + b.y) * f; area += f * 3; } - if (area == 0) return Cell(ring[0], 0, polygon); - return Cell(Point(x / area, y / area), 0, polygon); + + if (area == 0) { + return Cell(ring[0].x, ring[0].y, 0, polygon); + } + return Cell(x / area, y / area, 0, polygon); } + +const _empty = PolylabelResult(Point(0, 0), 0); diff --git a/pubspec.yaml b/pubspec.yaml index 88cdb50..53341f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,14 +2,15 @@ name: polylabel description: >- A fast algorithm for finding polygon pole of inaccessibility implemented as a Dart library. Useful for optimal placement of a text label on a polygon. -version: 1.0.1 +version: 2.0.0 repository: https://github.com/beroso/dart_polylabel environment: - sdk: '>=2.13.0 <3.0.0' + sdk: '>=3.00.0 <4.0.0' -dev_dependencies: - lints: ^1.0.0 - test: ^1.16.0 dependencies: collection: ^1.15.0 + +dev_dependencies: + lints: ^2.1.1 + test: ^1.16.0 diff --git a/test/polylabel_test.dart b/test/polylabel_test.dart index cf6b0c2..c98a5b4 100644 --- a/test/polylabel_test.dart +++ b/test/polylabel_test.dart @@ -6,15 +6,16 @@ import 'package:test/test.dart'; import 'fixtures/fixture_reader.dart'; -List> toPolygon(List original) { +List>> toPolygon(List original) { return original .map((polygon) => (polygon as List) - .map((p) => Point(p.first as num, p.last as num)) + .map((p) => + Point((p.first as num).toDouble(), (p.last as num).toDouble())) .toList()) .toList(); } -List> loadData(String fixtureFile) { +List>> loadData(String fixtureFile) { return toPolygon(jsonDecode(fixture(fixtureFile))); } @@ -24,9 +25,16 @@ void main() { final water2 = loadData('water2.json'); test('finds pole of inaccessibility for water1 and precision 1', () { - final p = polylabel(water1, precision: 1); - expect(p.point, Point(3865.85009765625, 2124.87841796875)); - expect(p.distance, 288.8493574779127); + final watch = Stopwatch()..start(); + + const N = 50; + for (int i = 0; i < N; ++i) { + final p = polylabel(water1, precision: 1); + expect(p.point, Point(3865.85009765625, 2124.87841796875)); + expect(p.distance, 288.8493574779127); + } + + print('Elapsed time for $N iterations of water1: ${watch.elapsed}'); }); test('finds pole of inaccessibility for water1 and precision 50', () {