Skip to content
This repository was archived by the owner on Feb 23, 2021. It is now read-only.

Support complex polygons for geofences #7

Merged
merged 1 commit into from
Nov 14, 2013
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 63 additions & 34 deletions lib/geofence.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,30 @@ var Geofence = function(vertices, granularity) {
throw new Error("Vertices must be an array");
}

this.vertices = vertices;
if (!Array.isArray(vertices[0])) {
throw new Error("Vertices must contain at least one point");
}

// Support complex polygons a la GeoJSON with inner rings removed, as well
// as single-array outer-ring polygons
if (!Array.isArray(vertices[0][0])) {
vertices = [vertices];
}

// Close the geofence(s) if necessary
for (var i = 0, ring; ring = vertices[i]; i++) {
if (ring[0][0] !== ring[ring.length - 1][0] ||
ring[0][1] !== ring[ring.length - 1][1]) {
ring.push(ring[0]);
}
}

this.vertices = vertices[0];

if (vertices.length > 1) {
this.holes = vertices.slice(1);
}

this.granularity = Math.floor(granularity) || 20;

this.minX = null;
Expand All @@ -15,11 +38,6 @@ var Geofence = function(vertices, granularity) {
this.tileWidth = null;
this.tileHeight = null;

// Close the geofence if necessary
if (vertices[0][0] !== vertices[vertices.length - 1][0] || vertices[0][1] !== vertices[vertices.length - 1][1]) {
vertices.push(vertices[0]);
}

this.tiles = {}; // Tracks which tiles are inside or intersecting the geofence (tiles outside are simply not tracked)

this.setInclusionTiles();
Expand All @@ -28,8 +46,6 @@ var Geofence = function(vertices, granularity) {
// this.testsOutside = 0;
// this.testsIntersecting = 0;
// this.timeHashing = 0;

return this;
};

Geofence.prototype = {
Expand All @@ -52,16 +68,27 @@ Geofence.prototype = {
return true;
} else if (intersects === 'x') {
// this.testsIntersecting++;
return utils.pointInPolygon(point, this.vertices);
var inside = utils.pointInPolygon(point, this.vertices);
if (!inside || !this.holes) {
return inside;
}
// If we do have holes cut out, and the point falls within the outer
// ring, ensure no inner rings exclude this point
for (var i = 0, hole; hole = this.holes[i]; i++) {
if (utils.pointInPolygon(point, hole)) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when the point is on the boundary of the hole ? Also totally unrelated, but the polygons that are constructed using gps coordinates might not work correctly over a large surface area because the polygon is not curved along the surface of the earth.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I don't think we're too concerned about that sort of boundary condition? Conceivably they'd flutter between the two sides on each ping based on the GPS error. As for what the code will actually do, being on the boundary means you're "in" that boundary, so it'd probably put them in the hole.

As for point 2, when ops creates their polygons they're looking at such a lat/lon projection in the first place and they're probably transcribing legal boundary areas directly in that same projection with Google Maps (or city bounds maps, etc) so it should still be accurate (because someone else did the projection work for them).

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting thoughts @dfellis, yes I didn't think about that, google maps uses kind of mercator projection. Also I went and had a peek in point-in-polygon library (didn't look at the code carefully though) which uses ray tracing and should work on complex polygons as well directly ( if we give a set of x-y coordinates for the polygon) but this way its simpler.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a performance issue. The magic behind in-n-out is the cached griding for each geofence ring, resulting in only needing to do an O(N) ray trace check in the worse case of intersecting tiles and O(1) otherwise. We need to compute a cached grid for each hole ring and use that instead for the hole checks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you actually read my implementation?

You're looking at the 'x' block, which only applies if the point lies on the boundary.

Trust me, I understand the point of the library, and for points that are clearly within the inner ring(s) at the given resolution, we short-circuit:

https://github.com/uber/in-n-out/pull/7/files#diff-068591b5cb8abf665e6104587d821d57R139

return false;
}
}
return true;
} else {
// this.testsOutside++;
return false;
}
},

setInclusionTiles: function() {
var xVertices = this.vertices.map(function(point) { return point[0];});
var yVertices = this.vertices.map(function(point) { return point[1];});
var xVertices = this.vertices.map(function(point) { return point[0]; });
var yVertices = this.vertices.map(function(point) { return point[1]; });

var minX = this.minX = Math.min.apply(null, xVertices);
var minY = this.minY = Math.min.apply(null, yVertices);
Expand All @@ -79,39 +106,41 @@ Geofence.prototype = {
var maxTileX = this.maxTileX = utils.project(maxX, tileWidth);
var maxTileY = this.maxTileY = utils.project(maxY, tileHeight);

var bBoxPoly = null;
var tileHash = null;
this.setExclusionTiles(this.vertices, true);
if (this.holes) {
this.holes.forEach(this.setExclusionTiles.bind(this));
}
},

setExclusionTiles: function(vertices, inclusive) {
var bBoxPoly;
var tileHash;

// console.log("");
for (var tileX = minTileX; tileX <= maxTileX; tileX++) {
// var row = '';
for (var tileY = minTileY; tileY <= maxTileY; tileY++) {
tileHash = (tileY - minTileY) * this.granularity + (tileX - minTileX);
for (var tileX = this.minTileX; tileX <= this.maxTileX; tileX++) {
for (var tileY = this.minTileY; tileY <= this.maxTileY; tileY++) {
tileHash = (tileY - this.minTileY) * this.granularity + (tileX - this.minTileX);
bBoxPoly = [
[tileX * tileWidth, tileY * tileHeight],
[(tileX + 1) * tileWidth, tileY * tileHeight],
[(tileX + 1) * tileWidth, (tileY + 1) * tileHeight],
[tileX * tileWidth, (tileY + 1) * tileHeight],
[tileX * tileWidth, tileY * tileHeight]
[tileX * this.tileWidth, tileY * this.tileHeight],
[(tileX + 1) * this.tileWidth, tileY * this.tileHeight],
[(tileX + 1) * this.tileWidth, (tileY + 1) * this.tileHeight],
[tileX * this.tileWidth, (tileY + 1) * this.tileHeight],
[tileX * this.tileWidth, tileY * this.tileHeight]
];

if (utils.haveIntersectingEdges(bBoxPoly, this.vertices) || utils.hasPointInPolygon(this.vertices, bBoxPoly)) {
if (utils.haveIntersectingEdges(bBoxPoly, vertices) ||
utils.hasPointInPolygon(vertices, bBoxPoly)) {
this.tiles[tileHash] = 'x';
// row += 'x';
// If the geofence doesn't have any points inside the tile bbox, then if the bbox has any point inside the geofence
// the bbox has all the points inside the geofence
} else if (utils.hasPointInPolygon(bBoxPoly, this.vertices)) {
this.tiles[tileHash] = 'i';
// row += 'i';
} else if (utils.hasPointInPolygon(bBoxPoly, vertices)) {
if (inclusive) {
this.tiles[tileHash] = 'i';
} else {
this.tiles[tileHash] = 'o';
}
} // else all points are outside the poly
else {
// row += '.';
}
}
// console.log(row);
}

return;
}
};

Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
"url": "git@github.com:uber/in-n-out.git"
},
"author": "Alain Rodriguez <eagle5command@gmail.com>",
"contributors": [
"Aiden Scandella <sc@ndella.com>"
],

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add yourself to the contributor list

"version": "0.0.8",

"version": "0.0.7",

"scripts": {
"test": "./test/tests.sh"
},
Expand Down
107 changes: 103 additions & 4 deletions test/test_geofence.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
var expect = require('chai').expect;

var utils = require('../lib/utils');
var geofence = require('../lib/geofence');
var Geofence = require('../lib/geofence');

var randomPoint = function(range) { return [Math.random()*range - range/2, Math.random()*range - range/2]; };
var randomPolygon = function(range, percentageOfRange) {
Expand All @@ -17,7 +17,7 @@ var randomPolygon = function(range, percentageOfRange) {
describe('Geofence.inside()', function() {
it('should always equal utils.pointInPolygon', function() {
var polygon = randomPolygon(2000, 0.1);
var gf = new geofence(polygon);
var gf = new Geofence(polygon);
var point = null;
for (var x = 0; x < 1000; x++) {
point = randomPoint(2000);
Expand Down Expand Up @@ -86,7 +86,7 @@ describe('Geofence.inside()', function() {
];
};

var gf = new geofence(polygon);
var gf = new Geofence(polygon);
var point = null;

var timeFunction = function(fn, iterations) {
Expand Down Expand Up @@ -114,4 +114,103 @@ describe('Geofence.inside()', function() {
expect(gfTime).to.be.below(inTime);
console.log("iterations: %d, innout: %dms, pointinpolygon: %dms", iterations, gfTime, inTime);
});
});
});

describe('Complex polygons', function() {
describe('with a single outer ring', function() {
it('behaves the same as pointInPolygon', function() {
var polygon = randomPolygon(2000, 0.1);
var geofence = new Geofence([polygon]);
var point = randomPoint(2000);
expect(geofence.inside(point)).to.equal(utils.pointInPolygon(point, polygon));
});
});

describe('with a single inner ring', function() {
// San Francisco peninsula
var polygon = [
[-122.19581253, 37.365653004],
[-122.49420166, 37.6447143555],
[-122.513364651, 37.6988438412],
[-122.355320255, 37.709494697],
[-122.355879585, 37.6928869743],
[-122.076452319, 37.4641785837],
[-122.19581253, 37.365653004]
];
var hole = [
[-122.404894594, 37.6388752809],
[-122.397411728, 37.6128435367],
[-122.379888562, 37.601737887],
[-122.336601607, 37.6116430059],
[-122.366248909, 37.645475542],
[-122.404894594, 37.6388752809]
];
var donutGeofence = new Geofence([polygon, hole]);

it('should return true for points outside the hole', function() {
var sanMateo = [-122.3131, 37.5542];
expect(donutGeofence.inside(sanMateo)).to.equal(true);
});

it('should return false for points inside the hole', function() {
var airportPoint = [-122.3750, 37.6189];
expect(donutGeofence.inside(airportPoint)).to.equal(false);
});

it('should compute the same as pointInPolygon', function() {
var minLatitude = -122.513;
var maxLatitude = -122.076;
var latitudeDelta = maxLatitude - minLatitude;
var minLongitude = 37.365;
var maxLongitude = 37.709;
var longitudeDelta = maxLongitude - minLongitude;

for (var i = 0; i < 100; i++) {
var latitude = minLatitude + Math.random() * latitudeDelta;
var longitude = minLongitude + Math.random() * longitudeDelta;
var point = [latitude, longitude];

var insideBounds = utils.pointInPolygon(point, polygon);
if (insideBounds) {
expect(donutGeofence.inside(point)).to.equal(!utils.pointInPolygon(point, hole));
} else {
expect(donutGeofence.inside(point)).to.equal(insideBounds);
}
}
});
});

describe('with two inner rings', function() {
var bounds = [
[-5, -5],
[-5, 5],
[5, 5],
[5, -5]
];
var firstRing = [
[3, 3],
[3, 4],
[4, 4],
[4, 3]
];
var secondRing = [
[-2, -2],
[-2, -1],
[-1, -1],
[-1, -2]
];
var twoHoleGeofence = new Geofence([bounds, firstRing, secondRing]);

it('respects the first ring', function() {
expect(twoHoleGeofence.inside([3.5, 3.5])).to.equal(false);
});

it('respects the second ring', function() {
expect(twoHoleGeofence.inside([-1.5, -1.5])).to.equal(false);
});

it('correctly identifies points outside both rings', function() {
expect(twoHoleGeofence.inside([0, 0])).to.equal(true);
});
});
});