diff --git a/js/data/bucket/symbol_bucket.js b/js/data/bucket/symbol_bucket.js index 8e15534bc0c..30410369300 100644 --- a/js/data/bucket/symbol_bucket.js +++ b/js/data/bucket/symbol_bucket.js @@ -14,6 +14,8 @@ var clipLine = require('../../symbol/clip_line'); var util = require('../../util/util'); var loadGeometry = require('../load_geometry'); var CollisionFeature = require('../../symbol/collision_feature'); +var findPoleOfInaccessibility = require('../../util/find_pole_of_inaccessibility'); +var classifyRings = require('../../util/classify_rings'); var shapeText = Shaping.shapeText; var shapeIcon = Shaping.shapeIcon; @@ -278,19 +280,27 @@ SymbolBucket.prototype.addFeature = function(lines, shapedText, shapedIcon, feat iconAlongLine = layout['icon-rotation-alignment'] === 'map' && layout['symbol-placement'] === 'line', mayOverlap = layout['text-allow-overlap'] || layout['icon-allow-overlap'] || layout['text-ignore-placement'] || layout['icon-ignore-placement'], - isLine = layout['symbol-placement'] === 'line', + symbolPlacement = layout['symbol-placement'], + isLine = symbolPlacement === 'line', textRepeatDistance = symbolMinDistance / 2; + var list = null; if (isLine) { - lines = clipLine(lines, 0, 0, EXTENT, EXTENT); + list = clipLine(lines, 0, 0, EXTENT, EXTENT); + } else { + // Only care about looping through the outer rings + list = classifyRings(lines, 0); } - for (var i = 0; i < lines.length; i++) { - var line = lines[i]; + for (var i = 0; i < list.length; i++) { + var anchors = null; + // At this point it is a list of points for a line or a list of polygon rings + var pointsOrRings = list[i]; + var line = null; // Calculate the anchor points around which you want to place labels - var anchors; if (isLine) { + line = pointsOrRings; anchors = getAnchors( line, symbolMinDistance, @@ -303,9 +313,13 @@ SymbolBucket.prototype.addFeature = function(lines, shapedText, shapedIcon, feat EXTENT ); } else { - anchors = [ new Anchor(line[0].x, line[0].y, 0) ]; + line = pointsOrRings[0]; + anchors = this.findPolygonAnchors(pointsOrRings); } + + // Here line is a list of points that is either the outer ring of a polygon or just a line + // For each potential label, create the placement features used to check for collisions, and the quads use for rendering. for (var j = 0, len = anchors.length; j < len; j++) { var anchor = anchors[j]; @@ -338,6 +352,23 @@ SymbolBucket.prototype.addFeature = function(lines, shapedText, shapedIcon, feat } }; +SymbolBucket.prototype.findPolygonAnchors = function(polygonRings) { + + var outerRing = polygonRings[0]; + if (outerRing.length === 0) { + return []; + } else if (outerRing.length < 3 || !util.isClosedPolygon(outerRing)) { + return [ new Anchor(outerRing[0].x, outerRing[0].y, 0) ]; + } + + var anchors = null; + // 16 here represents 2 pixels + var poi = findPoleOfInaccessibility(polygonRings, 16); + anchors = [ new Anchor(poi.x, poi.y, 0) ]; + + return anchors; +}; + SymbolBucket.prototype.anchorIsTooClose = function(text, repeatDistance, anchor) { var compareText = this.compareText; if (!(text in compareText)) { diff --git a/js/util/classify_rings.js b/js/util/classify_rings.js index bbb4eccea65..81ee0928498 100644 --- a/js/util/classify_rings.js +++ b/js/util/classify_rings.js @@ -1,6 +1,7 @@ 'use strict'; var quickselect = require('quickselect'); +var calculateSignedArea = require('./util').calculateSignedArea; // classifies an array of rings into polygons with outer rings and holes module.exports = function classifyRings(rings, maxRings) { @@ -46,13 +47,3 @@ module.exports = function classifyRings(rings, maxRings) { function compareAreas(a, b) { return b.area - a.area; } - -function calculateSignedArea(ring) { - var sum = 0; - for (var i = 0, len = ring.length, j = len - 1, p1, p2; i < len; j = i++) { - p1 = ring[i]; - p2 = ring[j]; - sum += (p2.x - p1.x) * (p1.y + p2.y); - } - return sum; -} diff --git a/js/util/find_pole_of_inaccessibility.js b/js/util/find_pole_of_inaccessibility.js new file mode 100644 index 00000000000..007366d7d6d --- /dev/null +++ b/js/util/find_pole_of_inaccessibility.js @@ -0,0 +1,127 @@ +'use strict'; +var Queue = require('tinyqueue'); +var Point = require('point-geometry'); +var distToSegmentSquared = require('./intersection_tests').distToSegmentSquared; + +/** + * Finds an approximation of a polygon's Pole Of Inaccessibiliy https://en.wikipedia.org/wiki/Pole_of_inaccessibility + * This is a copy of http://github.com/mapbox/polylabel adapted to use Points + * + * @param {Array>} List of polygon rings first item in array is the outer ring followed optionally by the list of holes, should be an element of the result of util/classify_rings + * @param {number} [precision=1] Specified in input coordinate units. If 0 returns after first run, if > 0 repeatedly narrows the search space until the radius of the area searched for the best pole is less than precision + * @param {bool} [debug=false] Print some statistics to the console during execution + * + * @returns {Point} Pole of Inaccessibiliy. + */ +module.exports = function (polygonRings, precision, debug) { + precision = precision || 1.0; + + // find the bounding box of the outer ring + var minX, minY, maxX, maxY; + var outerRing = polygonRings[0]; + for (var i = 0; i < outerRing.length; i++) { + var p = outerRing[i]; + if (!i || p.x < minX) minX = p.x; + if (!i || p.y < minY) minY = p.y; + if (!i || p.x > maxX) maxX = p.x; + if (!i || p.y > maxY) maxY = p.y; + } + + var width = maxX - minX; + var height = maxY - minY; + var cellSize = Math.min(width, height); + var h = cellSize / 2; + + // a priority queue of cells in order of their "potential" (max distance to polygon) + var cellQueue = new Queue(null, compareMax); + + // cover polygon with initial cells + for (var x = minX; x < maxX; x += cellSize) { + for (var y = minY; y < maxY; y += cellSize) { + cellQueue.push(new Cell(x + h, y + h, h, polygonRings)); + } + } + + // take centroid as the first best guess + var bestCell = getCentroidCell(polygonRings); + var numProbes = cellQueue.length; + + while (cellQueue.length) { + // pick the most promising cell from the queue + var cell = cellQueue.pop(); + + // update the best cell if we found a better one + if (cell.d > bestCell.d) { + bestCell = cell; + if (debug) console.log('found best %d after %d probes', Math.round(1e4 * cell.d) / 1e4, numProbes); + } + + // do not drill down further if there's no chance of a better solution + if (cell.max - bestCell.d <= precision) continue; + + // split the cell into four cells + h = cell.h / 2; + cellQueue.push(new Cell(cell.p.x - h, cell.p.y - h, h, polygonRings)); + cellQueue.push(new Cell(cell.p.x + h, cell.p.y - h, h, polygonRings)); + cellQueue.push(new Cell(cell.p.x - h, cell.p.y + h, h, polygonRings)); + cellQueue.push(new Cell(cell.p.x + h, cell.p.y + h, h, polygonRings)); + numProbes += 4; + } + + if (debug) { + console.log('num probes: ' + numProbes); + console.log('best distance: ' + bestCell.d); + } + + return bestCell.p; +}; + +function compareMax(a, b) { + return b.max - a.max; +} + +function Cell(x, y, h, polygon) { + this.p = new Point(x, y); + this.h = h; // half the cell size + this.d = pointToPolygonDist(this.p, polygon); // distance from cell center to polygon + this.max = this.d + this.h * Math.SQRT2; // max distance to polygon within a cell +} + +// signed distance from point to polygon outline (negative if point is outside) +function pointToPolygonDist(p, polygon) { + var inside = false; + var minDistSq = Infinity; + + for (var k = 0; k < polygon.length; k++) { + var ring = polygon[k]; + + for (var i = 0, len = ring.length, j = len - 1; i < len; j = i++) { + var a = ring[i]; + var b = ring[j]; + + if ((a.y > p.y !== b.y > p.y) && + (p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x)) inside = !inside; + + minDistSq = Math.min(minDistSq, distToSegmentSquared(p, a, b)); + } + } + + return (inside ? 1 : -1) * Math.sqrt(minDistSq); +} + +// get polygon centroid +function getCentroidCell(polygon) { + var area = 0; + var x = 0; + var y = 0; + var points = polygon[0]; + for (var i = 0, len = points.length, j = len - 1; i < len; j = i++) { + var a = points[i]; + var b = points[j]; + var f = a.x * b.y - b.x * a.y; + x += (a.x + b.x) * f; + y += (a.y + b.y) * f; + area += f * 3; + } + return new Cell(x / area, y / area, 0, polygon); +} diff --git a/js/util/intersection_tests.js b/js/util/intersection_tests.js index e21d4bb7eea..57f5fd1af6a 100644 --- a/js/util/intersection_tests.js +++ b/js/util/intersection_tests.js @@ -1,9 +1,12 @@ 'use strict'; +var isCounterClockwise = require('./util').isCounterClockwise; + module.exports = { multiPolygonIntersectsBufferedMultiPoint: multiPolygonIntersectsBufferedMultiPoint, multiPolygonIntersectsMultiPolygon: multiPolygonIntersectsMultiPolygon, - multiPolygonIntersectsBufferedMultiLine: multiPolygonIntersectsBufferedMultiLine + multiPolygonIntersectsBufferedMultiLine: multiPolygonIntersectsBufferedMultiLine, + distToSegmentSquared: distToSegmentSquared }; function multiPolygonIntersectsBufferedMultiPoint(multiPolygon, rings, radius) { @@ -98,12 +101,6 @@ function lineIntersectsLine(lineA, lineB) { return false; } - -// http://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/ -function isCounterClockwise(a, b, c) { - return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x); -} - function lineSegmentIntersectsLineSegment(a0, a1, b0, b1) { return isCounterClockwise(a0, b0, b1) !== isCounterClockwise(a1, b0, b1) && isCounterClockwise(a0, a1, b0) !== isCounterClockwise(a0, a1, b1); diff --git a/js/util/util.js b/js/util/util.js index ac627194b79..69ac24ff023 100644 --- a/js/util/util.js +++ b/js/util/util.js @@ -455,3 +455,60 @@ exports.warnOnce = function(message) { warnOnceHistory[message] = true; } }; + +/** + * Indicates if the provided Points are in a counter clockwise (true) or clockwise (false) order + * + * @param {Point} a + * @param {Point} b + * @param {Point} c + * + * @returns {boolean} true for a counter clockwise set of points + */ +// http://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/ +exports.isCounterClockwise = function(a, b, c) { + return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x); +}; + +/** + * Returns the signed area for the polygon ring. Postive areas are exterior rings and + * have a clockwise winding. Negative areas are interior rings and have a counter clockwise + * ordering. + * + * @param {Array} ring - Exterior or interior ring + * + * @returns {number} + */ +exports.calculateSignedArea = function(ring) { + var sum = 0; + for (var i = 0, len = ring.length, j = len - 1, p1, p2; i < len; j = i++) { + p1 = ring[i]; + p2 = ring[j]; + sum += (p2.x - p1.x) * (p1.y + p2.y); + } + return sum; +}; + +/** + * Detects closed polygons, first + last point are equal + * @param {Array} points array of points + * + * @return {boolean} true if the points are a closed polygon + */ +exports.isClosedPolygon = function(points) { + // If it is 2 points that are the same then it is a point + // If it is 3 points with start and end the same then it is a line + if (points.length < 4) + return false; + + var p1 = points[0]; + var p2 = points[points.length - 1]; + + if (Math.abs(p1.x - p2.x) > 0 || + Math.abs(p1.y - p2.y) > 0) { + return false; + } + + // polygon simplification can produce polygons with zero area and more than 3 points + return (Math.abs(exports.calculateSignedArea(points)) > 0.01); +}; diff --git a/package.json b/package.json index 9dab422903e..e7bdb316bdf 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "resolve-url": "^0.2.1", "shelf-pack": "^1.0.0", "supercluster": "^2.0.1", + "tinyqueue": "^1.1.0", "unassertify": "^2.0.0", "unitbezier": "^0.0.0", "vector-tile": "^1.3.0", @@ -59,7 +60,7 @@ "istanbul": "^0.4.2", "json-loader": "^0.5.4", "lodash": "^4.13.1", - "mapbox-gl-test-suite": "mapbox/mapbox-gl-test-suite#15e18375321393364907208715ab82c5a9c70f60", + "mapbox-gl-test-suite": "mapbox/mapbox-gl-test-suite#3e36b193a0c442a3fd863119f101afa6db97b32d", "memory-fs": "^0.3.0", "minifyify": "^7.0.1", "npm-run-all": "^3.0.0", diff --git a/test/js/util/find_pole_of_inaccessibility.test.js b/test/js/util/find_pole_of_inaccessibility.test.js new file mode 100644 index 00000000000..00efd311604 --- /dev/null +++ b/test/js/util/find_pole_of_inaccessibility.test.js @@ -0,0 +1,25 @@ +'use strict'; + +var test = require('tap').test; +var Point = require('point-geometry'); +var findPoleOfInaccessibility = require('../../../js/util/find_pole_of_inaccessibility'); + +test('polygon_poi', function(t) { + + var closedRing = [ + new Point(0, 0), + new Point(10, 10), + new Point(10, 0), + new Point(0, 0) + ]; + var closedRingHole = [ + new Point(2, 1), + new Point(6, 6), + new Point(6, 1), + new Point(2, 1) + ]; + t.deepEqual(findPoleOfInaccessibility([closedRing], 0.1), new Point(7.0703125, 2.9296875)); + t.deepEqual(findPoleOfInaccessibility([closedRing, closedRingHole], 0.1), new Point(7.96875, 2.03125)); + + t.end(); +});