diff --git a/.circleci/config.yml b/.circleci/config.yml index 16b1597..fdf2f45 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ jobs: build: docker: # specify the version you desire here - - image: circleci/node:8.9 + - image: circleci/node:lts # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images diff --git a/quadtree.js b/quadtree.js index 481acfb..b6fa0b7 100644 --- a/quadtree.js +++ b/quadtree.js @@ -144,7 +144,10 @@ class Circle { } class QuadTree { - constructor(boundary, capacity) { + DEFAULT_CAPACITY = 8; + MAX_DEPTH = 8; + + constructor(boundary, capacity = this.DEFAULT_CAPACITY, _depth = 0) { if (!boundary) { throw TypeError('boundary is null or undefined'); } @@ -157,10 +160,13 @@ class QuadTree { if (capacity < 1) { throw RangeError('capacity must be greater than 0'); } + this.boundary = boundary; this.capacity = capacity; this.points = []; this.divided = false; + + this.depth = _depth; } get children() { @@ -177,7 +183,6 @@ class QuadTree { } static create() { - let DEFAULT_CAPACITY = 8; if (arguments.length === 0) { if (typeof width === "undefined") { throw new TypeError("No global width defined"); @@ -186,49 +191,54 @@ class QuadTree { throw new TypeError("No global height defined"); } let bounds = new Rectangle(width / 2, height / 2, width, height); - return new QuadTree(bounds, DEFAULT_CAPACITY); + return new QuadTree(bounds, this.DEFAULT_CAPACITY); } if (arguments[0] instanceof Rectangle) { - let capacity = arguments[1] || DEFAULT_CAPACITY; + let capacity = arguments[1] || this.DEFAULT_CAPACITY; return new QuadTree(arguments[0], capacity); } if (typeof arguments[0] === "number" && typeof arguments[1] === "number" && typeof arguments[2] === "number" && typeof arguments[3] === "number") { - let capacity = arguments[4] || DEFAULT_CAPACITY; + let capacity = arguments[4] || this.DEFAULT_CAPACITY; return new QuadTree(new Rectangle(arguments[0], arguments[1], arguments[2], arguments[3]), capacity); } throw new TypeError('Invalid parameters'); } - toJSON(isChild) { - let obj = { points: this.points }; + toJSON() { + let obj = {}; + if (this.divided) { - if (this.northeast.points.length > 0) { - obj.ne = this.northeast.toJSON(true); + if (this.northeast.divided || this.northeast.points.length > 0) { + obj.ne = this.northeast.toJSON(); } - if (this.northwest.points.length > 0) { - obj.nw = this.northwest.toJSON(true); + if (this.northwest.divided || this.northwest.points.length > 0) { + obj.nw = this.northwest.toJSON(); } - if (this.southeast.points.length > 0) { - obj.se = this.southeast.toJSON(true); + if (this.southeast.divided || this.southeast.points.length > 0) { + obj.se = this.southeast.toJSON(); } - if (this.southwest.points.length > 0) { - obj.sw = this.southwest.toJSON(true); + if (this.southwest.divided || this.southwest.points.length > 0) { + obj.sw = this.southwest.toJSON(); } + } else { + obj.points = this.points; } - if (!isChild) { + + if (this.depth === 0) { obj.capacity = this.capacity; obj.x = this.boundary.x; obj.y = this.boundary.y; obj.w = this.boundary.w; obj.h = this.boundary.h; } + return obj; } - static fromJSON(obj, x, y, w, h, capacity) { + static fromJSON(obj, x, y, w, h, capacity, depth) { if (typeof x === "undefined") { if ("x" in obj) { x = obj.x; @@ -236,56 +246,77 @@ class QuadTree { w = obj.w; h = obj.h; capacity = obj.capacity; + depth = 0; } else { throw TypeError("JSON missing boundary information"); } } - let qt = new QuadTree(new Rectangle(x, y, w, h), capacity); - qt.points = obj.points; + + let qt = new QuadTree(new Rectangle(x, y, w, h), capacity, depth); + + qt.points = obj.points ?? null; + qt.divided = qt.points === null; // points are set to null on subdivide + if ( "ne" in obj || "nw" in obj || "se" in obj || "sw" in obj ) { - let x = qt.boundary.x; - let y = qt.boundary.y; - let w = qt.boundary.w / 2; - let h = qt.boundary.h / 2; + const x = qt.boundary.x; + const y = qt.boundary.y; + const w = qt.boundary.w / 2; + const h = qt.boundary.h / 2; if ("ne" in obj) { - qt.northeast = QuadTree.fromJSON(obj.ne, x + w/2, y - h/2, w, h, capacity); + qt.northeast = QuadTree.fromJSON(obj.ne, x + w/2, y - h/2, w, h, capacity, depth + 1); } else { - qt.northeast = new QuadTree(qt.boundary.subdivide('ne'), capacity); + qt.northeast = new QuadTree(qt.boundary.subdivide('ne'), capacity, depth + 1); } if ("nw" in obj) { - qt.northwest = QuadTree.fromJSON(obj.nw, x - w/2, y - h/2, w, h, capacity); + qt.northwest = QuadTree.fromJSON(obj.nw, x - w/2, y - h/2, w, h, capacity, depth + 1); } else { - qt.northwest = new QuadTree(qt.boundary.subdivide('nw'), capacity); + qt.northwest = new QuadTree(qt.boundary.subdivide('nw'), capacity, depth + 1); } if ("se" in obj) { - qt.southeast = QuadTree.fromJSON(obj.se, x + w/2, y + h/2, w, h, capacity); + qt.southeast = QuadTree.fromJSON(obj.se, x + w/2, y + h/2, w, h, capacity, depth + 1); } else { - qt.southeast = new QuadTree(qt.boundary.subdivide('se'), capacity); + qt.southeast = new QuadTree(qt.boundary.subdivide('se'), capacity, depth + 1); } if ("sw" in obj) { - qt.southwest = QuadTree.fromJSON(obj.sw, x - w/2, y + h/2, w, h, capacity); + qt.southwest = QuadTree.fromJSON(obj.sw, x - w/2, y + h/2, w, h, capacity, depth + 1); } else { - qt.southwest = new QuadTree(qt.boundary.subdivide('sw'), capacity); + qt.southwest = new QuadTree(qt.boundary.subdivide('sw'), capacity, depth + 1); } - - qt.divided = true; } + return qt; } subdivide() { - this.northeast = new QuadTree(this.boundary.subdivide('ne'), this.capacity); - this.northwest = new QuadTree(this.boundary.subdivide('nw'), this.capacity); - this.southeast = new QuadTree(this.boundary.subdivide('se'), this.capacity); - this.southwest = new QuadTree(this.boundary.subdivide('sw'), this.capacity); + this.northeast = new QuadTree(this.boundary.subdivide('ne'), this.capacity, this.depth + 1); + this.northwest = new QuadTree(this.boundary.subdivide('nw'), this.capacity, this.depth + 1); + this.southeast = new QuadTree(this.boundary.subdivide('se'), this.capacity, this.depth + 1); + this.southwest = new QuadTree(this.boundary.subdivide('sw'), this.capacity, this.depth + 1); this.divided = true; + + // Move points to children. + // This improves performance by placing points + // in the smallest available rectangle. + for (const p of this.points) { + const inserted = + this.northeast.insert(p) || + this.northwest.insert(p) || + this.southeast.insert(p) || + this.southwest.insert(p); + + if (!inserted) { + throw RangeError('capacity must be greater than 0'); + } + } + + this.points = null; } insert(point) { @@ -293,12 +324,15 @@ class QuadTree { return false; } - if (this.points.length < this.capacity) { - this.points.push(point); - return true; - } - if (!this.divided) { + if ( + this.points.length < this.capacity || + this.depth === this.MAX_DEPTH + ) { + this.points.push(point); + return true; + } + this.subdivide(); } @@ -319,16 +353,18 @@ class QuadTree { return found; } - for (let p of this.points) { - if (range.contains(p)) { - found.push(p); - } - } if (this.divided) { this.northwest.query(range, found); this.northeast.query(range, found); this.southwest.query(range, found); this.southeast.query(range, found); + return found; + } + + for (const p of this.points) { + if (range.contains(p)) { + found.push(p); + } } return found; @@ -346,46 +382,50 @@ class QuadTree { kNearest(searchPoint, maxCount, sqMaxDistance, furthestSqDistance, foundSoFar) { let found = []; - this.children.sort((a, b) => a.boundary.sqDistanceFrom(searchPoint) - b.boundary.sqDistanceFrom(searchPoint)) - .forEach((child) => { - const sqDist = child.boundary.sqDistanceFrom(searchPoint); - if (sqDist > sqMaxDistance) { - return; - } else if (foundSoFar < maxCount || sqDist < furthestSqDistance) { - const result = child.kNearest(searchPoint, maxCount, sqMaxDistance, furthestSqDistance, foundSoFar); - const childPoints = result.found; - found = found.concat(childPoints); - foundSoFar += childPoints.length; - furthestSqDistance = result.furthestSqDistance; - } - }); - - this.points - .sort((a, b) => a.sqDistanceFrom(searchPoint) - b.sqDistanceFrom(searchPoint)) - .forEach((p) => { - const sqDist = p.sqDistanceFrom(searchPoint); - if (sqDist > sqMaxDistance) { - return; - } else if (foundSoFar < maxCount || sqDist < furthestSqDistance) { - found.push(p); - furthestSqDistance = Math.max(sqDist, furthestSqDistance); - foundSoFar++; - } - }); + if (this.divided) { + this.children + .sort((a, b) => a.boundary.sqDistanceFrom(searchPoint) - b.boundary.sqDistanceFrom(searchPoint)) + .forEach((child) => { + const sqDistance = child.boundary.sqDistanceFrom(searchPoint); + if (sqDistance > sqMaxDistance) { + return; + } else if (foundSoFar < maxCount || sqDistance < furthestSqDistance) { + const result = child.kNearest(searchPoint, maxCount, sqMaxDistance, furthestSqDistance, foundSoFar); + const childPoints = result.found; + found = found.concat(childPoints); + foundSoFar += childPoints.length; + furthestSqDistance = result.furthestSqDistance; + } + }); + } else { + this.points + .sort((a, b) => a.sqDistanceFrom(searchPoint) - b.sqDistanceFrom(searchPoint)) + .forEach((p) => { + const sqDistance = p.sqDistanceFrom(searchPoint); + if (sqDistance > sqMaxDistance) { + return; + } else if (foundSoFar < maxCount || sqDistance < furthestSqDistance) { + found.push(p); + furthestSqDistance = Math.max(sqDistance, furthestSqDistance); + foundSoFar++; + } + }); + } return { found: found.sort((a, b) => a.sqDistanceFrom(searchPoint) - b.sqDistanceFrom(searchPoint)).slice(0, maxCount), - furthestDistance: Math.sqrt(furthestSqDistance), + furthestSqDistance: Math.sqrt(furthestSqDistance), }; } forEach(fn) { - this.points.forEach(fn); if (this.divided) { this.northeast.forEach(fn); this.northwest.forEach(fn); this.southeast.forEach(fn); this.southwest.forEach(fn); + } else { + this.points.forEach(fn); } } @@ -411,14 +451,16 @@ class QuadTree { } get length() { - let count = this.points.length; if (this.divided) { - count += this.northwest.length; - count += this.northeast.length; - count += this.southwest.length; - count += this.southeast.length; + return ( + this.northwest.length + + this.northeast.length + + this.southwest.length + + this.southeast.length + ); } - return count; + + return this.points.length; } } diff --git a/test/quadtree.spec.js b/test/quadtree.spec.js index 094e2e0..21b8b68 100644 --- a/test/quadtree.spec.js +++ b/test/quadtree.spec.js @@ -162,43 +162,44 @@ describe('QuadTree', () => { }); describe('when subdivided', () => { beforeEach(() => { - quadtree.insert(new Point(100,200)); - quadtree.insert(new Point(100,200)); - quadtree.insert(new Point(100,200)); - quadtree.insert(new Point(100,200)); + // one for each quarter + quadtree.insert(new Point(100 - 5,200 - 5)); + quadtree.insert(new Point(100 + 5,200 - 5)); + quadtree.insert(new Point(100 - 5,200 + 5)); + quadtree.insert(new Point(100 + 5,200 + 5)); }); it('correctly adds to northwest', () => { quadtree.insert(new Point(100 - 10, 200 - 10)); - expect(quadtree.northwest.points).to.have.length(1); + expect(quadtree.northwest.points).to.have.length(2); }); it('returns true when added to northwest', () => { expect(quadtree.insert(new Point(100 - 10, 200 - 10))).to.be.true; }); it('correctly adds to northeast', () => { - quadtree.insert(new Point(100 + 5, 200 - 10)); - expect(quadtree.northeast.points).to.have.length(1); + quadtree.insert(new Point(100 + 10, 200 - 10)); + expect(quadtree.northeast.points).to.have.length(2); }); it('returns true when added to northeast', () => { - expect(quadtree.insert(new Point(100 + 5, 200 - 10))).to.be.true; + expect(quadtree.insert(new Point(100 + 10, 200 - 10))).to.be.true; }); it('correctly adds to southwest', () => { quadtree.insert(new Point(100 - 10, 200 + 10)); - expect(quadtree.southwest.points).to.have.length(1); + expect(quadtree.southwest.points).to.have.length(2); }); it('returns true when added to southwest', () => { expect(quadtree.insert(new Point(100 - 10, 200 + 10))).to.be.true; }); it('correctly adds to southeast', () => { - quadtree.insert(new Point(100 + 5, 200 + 10)); - expect(quadtree.southeast.points).to.have.length(1); + quadtree.insert(new Point(100 + 10, 200 + 10)); + expect(quadtree.southeast.points).to.have.length(2); }); it('returns true when added to southeast', () => { - expect(quadtree.insert(new Point(100 + 5, 200 + 10))).to.be.true; + expect(quadtree.insert(new Point(100 + 10, 200 + 10))).to.be.true; }); it('does not trigger multiple subdivisions', () => { - quadtree.insert(new Point(100 + 5, 200 + 10)); + quadtree.insert(new Point(100 + 10, 200 + 10)); let temp = quadtree.northeast; - quadtree.insert(new Point(100 + 5, 200 + 10)); + quadtree.insert(new Point(100 + 10, 200 + 10)); expect(quadtree.northeast).to.equal(temp); }); });