From 95a71748f310361742184c5d3bd558f589992eaf Mon Sep 17 00:00:00 2001 From: Chris Hallberg Date: Mon, 25 Oct 2021 16:45:21 -0400 Subject: [PATCH 1/3] Move points to children, set to null. Adjust tests. --- quadtree.js | 224 ++++++++++++++++++++++++++---------------- test/quadtree.spec.js | 29 +++--- 2 files changed, 157 insertions(+), 96 deletions(-) diff --git a/quadtree.js b/quadtree.js index a06b314..219f9e3 100644 --- a/quadtree.js +++ b/quadtree.js @@ -11,11 +11,17 @@ class Point { this.userData = data; } - // Pythagorus: a^2 = b^2 + c^2 - distanceFrom(other) { + // Skips Math.sqrt for faster performance + sqDistanceFrom(other) { const dx = other.x - this.x; const dy = other.y - this.y; - return Math.sqrt(dx * dx + dy * dy); + + return dx * dx + dy * dy; + } + + // Pythagorus: a^2 = b^2 + c^2 + distanceFrom(other) { + return Math.sqrt(this.sqDistanceFrom(other)); } } @@ -80,11 +86,17 @@ class Rectangle { ); } - distanceFrom(point) { + // Skips Math.sqrt for faster performance + sqDistanceFrom(point) { const dx = this.xDistanceFrom(point); const dy = this.yDistanceFrom(point); - return Math.sqrt(dx * dx + dy * dy); + return dx * dx + dy * dy; + } + + // Pythagorus: a^2 = b^2 + c^2 + distanceFrom(point) { + return Math.sqrt(this.sqDistanceFrom(point)); } } @@ -132,7 +144,7 @@ class Circle { } class QuadTree { - constructor(boundary, capacity) { + constructor(boundary, capacity, maxDepth = 16, depth = 0) { if (!boundary) { throw TypeError('boundary is null or undefined'); } @@ -145,10 +157,14 @@ 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.maxDepth = maxDepth; + this.depth = depth; } get children() { @@ -190,33 +206,39 @@ class QuadTree { 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.maxDepth = this.maxDepth; 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, maxDepth, depth) { if (typeof x === "undefined") { if ("x" in obj) { x = obj.x; @@ -224,56 +246,81 @@ class QuadTree { w = obj.w; h = obj.h; capacity = obj.capacity; + maxDepth = obj.maxDepth; + 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, maxDepth, 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; + + const staticArgs = [capacity, maxDepth, depth + 1]; 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, ...staticArgs); } else { - qt.northeast = new QuadTree(qt.boundary.subdivide('ne'), capacity); + qt.northeast = new QuadTree(qt.boundary.subdivide('ne'), ...staticArgs); } 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, ...staticArgs); } else { - qt.northwest = new QuadTree(qt.boundary.subdivide('nw'), capacity); + qt.northwest = new QuadTree(qt.boundary.subdivide('nw'), ...staticArgs); } 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, ...staticArgs); } else { - qt.southeast = new QuadTree(qt.boundary.subdivide('se'), capacity); + qt.southeast = new QuadTree(qt.boundary.subdivide('se'), ...staticArgs); } 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, ...staticArgs); } else { - qt.southwest = new QuadTree(qt.boundary.subdivide('sw'), capacity); + qt.southwest = new QuadTree(qt.boundary.subdivide('sw'), ...staticArgs); } - - 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); + const staticArgs = [this.capacity, this.maxDepth, this.depth + 1]; + + this.northeast = new QuadTree(this.boundary.subdivide('ne'), ...staticArgs); + this.northwest = new QuadTree(this.boundary.subdivide('nw'), ...staticArgs); + this.southeast = new QuadTree(this.boundary.subdivide('se'), ...staticArgs); + this.southwest = new QuadTree(this.boundary.subdivide('sw'), ...staticArgs); 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) { @@ -281,12 +328,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.maxDepth + ) { + this.points.push(point); + return true; + } + this.subdivide(); } @@ -307,16 +357,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; @@ -332,46 +384,52 @@ class QuadTree { kNearest(searchPoint, maxCount, maxDistance, furthestDistance, foundSoFar) { let found = []; + const sqMaxDistance = maxDistance ** 2; - this.children.sort((a, b) => a.boundary.distanceFrom(searchPoint) - b.boundary.distanceFrom(searchPoint)) - .forEach((child) => { - const distance = child.boundary.distanceFrom(searchPoint); - if (distance > maxDistance) { - return; - } else if (foundSoFar < maxCount || distance < furthestDistance) { - const result = child.kNearest(searchPoint, maxCount, maxDistance, furthestDistance, foundSoFar); - const childPoints = result.found; - found = found.concat(childPoints); - foundSoFar += childPoints.length; - furthestDistance = result.furthestDistance; - } - }); - - this.points.sort((a, b) => a.distanceFrom(searchPoint) - b.distanceFrom(searchPoint)) - .forEach((p) => { - const distance = p.distanceFrom(searchPoint); - if (distance > maxDistance) { - return; - } else if (foundSoFar < maxCount || distance < furthestDistance) { - found.push(p); - furthestDistance = Math.max(distance, furthestDistance); - 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 < furthestDistance) { + const result = child.kNearest(searchPoint, maxCount, maxDistance, furthestDistance, foundSoFar); + const childPoints = result.found; + found = found.concat(childPoints); + foundSoFar += childPoints.length; + furthestDistance = result.furthestDistance; + } + }); + } 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 < furthestDistance) { + found.push(p); + furthestDistance = Math.max(sqDistance, furthestDistance); + foundSoFar++; + } + }); + } return { - found: found.sort((a, b) => a.distanceFrom(searchPoint) - b.distanceFrom(searchPoint)).slice(0, maxCount), - furthestDistance + found: found.sort((a, b) => a.sqDistanceFrom(searchPoint) - b.sqDistanceFrom(searchPoint)).slice(0, maxCount), + furthestDistance: Math.sqrt(furthestDistance), }; } 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); } } @@ -393,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); }); }); From 034286ce59f88307a9d4fffc21aaeaa9510331ad Mon Sep 17 00:00:00 2001 From: Chris Hallberg Date: Mon, 25 Oct 2021 16:56:32 -0400 Subject: [PATCH 2/3] Bump node version to LTS. --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 08fccf6f43d9ad03a29e64c47720c49197b6135a Mon Sep 17 00:00:00 2001 From: Chris Hallberg Date: Mon, 25 Oct 2021 17:11:40 -0400 Subject: [PATCH 3/3] Remove maxDepth from constructor to simplify code. Consolidate with DEFAULT_CAPACITY. --- quadtree.js | 54 +++++++++++++++++++++++++---------------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/quadtree.js b/quadtree.js index 219f9e3..c8b0ec2 100644 --- a/quadtree.js +++ b/quadtree.js @@ -144,7 +144,10 @@ class Circle { } class QuadTree { - constructor(boundary, capacity, maxDepth = 16, depth = 0) { + DEFAULT_CAPACITY = 8; + MAX_DEPTH = 8; + + constructor(boundary, capacity = this.DEFAULT_CAPACITY, _depth = 0) { if (!boundary) { throw TypeError('boundary is null or undefined'); } @@ -163,8 +166,7 @@ class QuadTree { this.points = []; this.divided = false; - this.maxDepth = maxDepth; - this.depth = depth; + this.depth = _depth; } get children() { @@ -181,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"); @@ -190,17 +191,17 @@ 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'); @@ -228,7 +229,6 @@ class QuadTree { if (this.depth === 0) { obj.capacity = this.capacity; - obj.maxDepth = this.maxDepth; obj.x = this.boundary.x; obj.y = this.boundary.y; obj.w = this.boundary.w; @@ -238,7 +238,7 @@ class QuadTree { return obj; } - static fromJSON(obj, x, y, w, h, capacity, maxDepth, depth) { + static fromJSON(obj, x, y, w, h, capacity, depth) { if (typeof x === "undefined") { if ("x" in obj) { x = obj.x; @@ -246,14 +246,13 @@ class QuadTree { w = obj.w; h = obj.h; capacity = obj.capacity; - maxDepth = obj.maxDepth; depth = 0; } else { throw TypeError("JSON missing boundary information"); } } - let qt = new QuadTree(new Rectangle(x, y, w, h), capacity, maxDepth, depth); + 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 @@ -269,27 +268,25 @@ class QuadTree { const w = qt.boundary.w / 2; const h = qt.boundary.h / 2; - const staticArgs = [capacity, maxDepth, depth + 1]; - if ("ne" in obj) { - qt.northeast = QuadTree.fromJSON(obj.ne, x + w/2, y - h/2, w, h, ...staticArgs); + 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'), ...staticArgs); + 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, ...staticArgs); + 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'), ...staticArgs); + 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, ...staticArgs); + 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'), ...staticArgs); + 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, ...staticArgs); + 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'), ...staticArgs); + qt.southwest = new QuadTree(qt.boundary.subdivide('sw'), capacity, depth + 1); } } @@ -297,12 +294,10 @@ class QuadTree { } subdivide() { - const staticArgs = [this.capacity, this.maxDepth, this.depth + 1]; - - this.northeast = new QuadTree(this.boundary.subdivide('ne'), ...staticArgs); - this.northwest = new QuadTree(this.boundary.subdivide('nw'), ...staticArgs); - this.southeast = new QuadTree(this.boundary.subdivide('se'), ...staticArgs); - this.southwest = new QuadTree(this.boundary.subdivide('sw'), ...staticArgs); + 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; @@ -310,7 +305,8 @@ class QuadTree { // This improves performance by placing points // in the smallest available rectangle. for (const p of this.points) { - const inserted = this.northeast.insert(p) || + const inserted = + this.northeast.insert(p) || this.northwest.insert(p) || this.southeast.insert(p) || this.southwest.insert(p); @@ -331,7 +327,7 @@ class QuadTree { if (!this.divided) { if ( this.points.length < this.capacity || - this.depth === this.maxDepth + this.depth === this.MAX_DEPTH ) { this.points.push(point); return true;