@timohausmann/quadtree-ts
Version:
Quadtree Typescript Implementation
901 lines (895 loc) • 26.8 kB
JavaScript
'use strict';
/**
* Class representing a Quadtree node.
*
* @example
* ```typescript
* const tree = new Quadtree({
* width: 100,
* height: 100,
* x: 0, // optional, default: 0
* y: 0, // optional, default: 0
* maxObjects: 10, // optional, default: 10
* minLevels: 0, // optional, default: 0
* maxLevels: 4, // optional, default: 4
* });
* ```
*
* @example Typescript: If you like to be explicit, you optionally can pass in a generic type for objects to be stored in the Quadtree:
* ```typescript
* class GameEntity extends Rectangle {
* ...
* }
* const tree = new Quadtree<GameEntity>({
* width: 100,
* height: 100,
* });
* ```
*/
class Quadtree {
/**
* Quadtree Constructor
* @param props - bounds and properties of the node
* @param level - depth level (internal use only, required for subnodes)
*/
constructor(props, level = 0) {
this.bounds = {
x: props.x || 0,
y: props.y || 0,
width: props.width,
height: props.height,
};
this.maxObjects = typeof props.maxObjects === 'number' ? props.maxObjects : 10;
this.minLevels = typeof props.minLevels === 'number' ? props.minLevels : 0;
this.maxLevels = typeof props.maxLevels === 'number' ? props.maxLevels : 4;
this.level = level;
if (this.minLevels > this.maxLevels) {
throw new Error(`minLevels (${this.minLevels}) must be less than maxLevels (${this.maxLevels})`);
}
this.objects = [];
this.nodes = [];
if (this.level < this.minLevels) {
this.split();
}
}
/**
* Get the quadrant (subnode indexes) an object belongs to.
*
* @example Mostly for internal use but you can call it like so:
* ```typescript
* const tree = new Quadtree({ width: 100, height: 100 });
* const rectangle = new Rectangle({ x: 25, y: 25, width: 10, height: 10 });
* const indexes = tree.getIndex(rectangle);
* console.log(indexes); // [1]
* ```
*
* @param obj - object to be checked
* @returns Array containing indexes of intersecting subnodes (0-3 = top-right, top-left, bottom-left, bottom-right).
*/
getIndex(obj) {
return obj.qtIndex(this.bounds);
}
/**
* Split the node into 4 subnodes.
* @internal Mostly for internal use! You should only call this yourself if you know what you are doing.
*
* @example Manual split:
* ```typescript
* const tree = new Quadtree({ width: 100, height: 100 });
* tree.split();
* console.log(tree); // now tree has four subnodes
* ```
*/
split() {
const level = this.level + 1, width = this.bounds.width / 2, height = this.bounds.height / 2, x = this.bounds.x, y = this.bounds.y;
const coords = [
{ x: x + width, y: y },
{ x: x, y: y },
{ x: x, y: y + height },
{ x: x + width, y: y + height },
];
const Constructor = this.constructor;
for (let i = 0; i < 4; i++) {
this.nodes[i] = new Constructor({
x: coords[i].x,
y: coords[i].y,
width,
height,
maxObjects: this.maxObjects,
minLevels: this.minLevels,
maxLevels: this.maxLevels,
}, level);
}
}
/**
* Insert an object into the node. If the node
* exceeds the capacity, it will split and add all
* objects to their corresponding subnodes.
*
* @example you can use any shape here (or object with a qtIndex method, see README):
* ```typescript
* const tree = new Quadtree({ width: 100, height: 100 });
* tree.insert(new Rectangle({ x: 25, y: 25, width: 10, height: 10, data: 'data' }));
* tree.insert(new Circle({ x: 25, y: 25, r: 10, data: 512 }));
* tree.insert(new Line({ x1: 25, y1: 25, x2: 60, y2: 40, data: { custom: 'property'} }));
* ```
*
* @param obj - Object to be added.
*/
insert(obj) {
// if we have subnodes, call insert on matching subnodes
if (this.nodes.length) {
const indexes = this.getIndex(obj);
for (let i = 0; i < indexes.length; i++) {
this.nodes[indexes[i]].insert(obj);
}
return;
}
// otherwise, store object here
this.objects.push(obj);
// maxObjects exceeded and can still split deeper
if (this.objects.length > this.maxObjects && this.level < this.maxLevels) {
// create subnodes
this.split();
// add all objects to their corresponding subnode
for (let i = 0; i < this.objects.length; i++) {
const indexes = this.getIndex(this.objects[i]);
for (let k = 0; k < indexes.length; k++) {
this.nodes[indexes[k]].insert(this.objects[i]);
}
}
// clean up this node
this.objects = [];
}
}
/**
* Return all objects that could collide with the given geometry.
*
* @example Just like insert, you can use any shape here (or object with a qtIndex method, see README):
* ```typescript
* tree.retrieve(new Rectangle({ x: 25, y: 25, width: 10, height: 10, data: 'data' }));
* tree.retrieve(new Circle({ x: 25, y: 25, r: 10, data: 512 }));
* tree.retrieve(new Line({ x1: 25, y1: 25, x2: 60, y2: 40, data: { custom: 'property'} }));
* ```
*
* @param obj - geometry to be checked
* @returns Array containing all detected objects.
*/
retrieve(obj) {
const indexes = this.getIndex(obj);
let returnObjects = this.objects;
// if we have subnodes, retrieve their objects
if (this.nodes.length) {
for (let i = 0; i < indexes.length; i++) {
returnObjects = returnObjects.concat(this.nodes[indexes[i]].retrieve(obj));
}
}
// remove duplicates
if (this.level === 0) {
return Array.from(new Set(returnObjects));
}
return returnObjects;
}
/**
* Remove an object from the tree.
* If you have to remove many objects, consider clearing the entire tree and rebuilding it or use the `fast` flag to cleanup after the last removal.
* @beta
*
* @example
* ```typescript
* const tree = new Quadtree({ width: 100, height: 100 });
* const circle = new Circle({ x: 25, y: 25, r: 10, data: 512 });
* tree.insert(circle);
* tree.remove(circle);
* ```
*
* @example Bulk fast removals and final cleanup:
* ```javascript
* const tree = new Quadtree({ width: 100, height: 100 });
* const rects = [];
* for(let i=0; i<20; i++) {
* rects[i] = new Rectangle({ x: 25, y: 25, width: 50, height: 50 });
* tree.insert(rects[i]);
* }
* for(let i=rects.length-1; i>0; i--) {
* //fast=true – just remove the object (may leaves vacant subnodes)
* //fast=false – cleanup empty subnodes (default)
* const fast = (i !== 0);
* tree.remove(rects[i], fast);
* }
* ```
*
* @param obj - Object to be removed.
* @param fast - Set to true to increase performance temporarily by preventing cleanup of empty subnodes (optional, default: false).
* @returns Weather or not the object was removed from THIS node (no recursive check).
*/
remove(obj, fast = false) {
const indexOf = this.objects.indexOf(obj);
// remove objects
if (indexOf > -1) {
this.objects.splice(indexOf, 1);
}
// remove from all subnodes
for (let i = 0; i < this.nodes.length; i++) {
this.nodes[i].remove(obj);
}
// remove all empty subnodes (respects minLevels constraint)
if (this.level === 0 && !fast) {
this.join();
}
return indexOf !== -1;
}
/**
* Update an object already in the tree (shorthand for remove and insert).
* If you have to update many objects, consider clearing and rebuilding the
* entire tree or use the `fast` flag to cleanup after the last update.
* @beta
*
* @example
* ```typescript
* const tree = new Quadtree({ width: 100, height: 100, maxObjects: 1 });
* const rect1 = new Rectangle({ x: 25, y: 25, width: 10, height: 10 });
* const rect2 = new Rectangle({ x: 25, y: 25, width: 10, height: 10 });
* tree.insert(rect1);
* tree.insert(rect2);
* rect1.x = 75;
* rect1.y = 75;
* tree.update(rect1);
* ```
* @example Bulk fast update and final cleanup:
* ```javascript
* const tree = new Quadtree({ width: 100, height: 100 });
* const rects = [];
* for(let i=0; i<20; i++) {
* rects[i] = new Rectangle({ x: 20, y: 20, width: 20, height: 20 });
* tree.insert(rects[i]);
* }
* for(let i=rects.length-1; i>0; i--) {
* rects[i].x = 20 + Math.random()*60;
* rects[i].y = 20 + Math.random()*60;
* //fast=true – just re-insert the object (may leaves vacant subnodes)
* //fast=false – cleanup empty subnodes (default)
* const fast = (i !== 0);
* tree.update(rects[i], fast);
* }
* ```
*
* @param obj - Object to be updated.
* @param fast - Set to true to increase performance temporarily by preventing cleanup of empty subnodes (optional, default: false).
*/
update(obj, fast = false) {
this.remove(obj, fast);
this.insert(obj);
}
/**
* The opposite of a split: merge and dissolve subnodes (when total object count doesn't exceed maxObjects).
*
* @example Manual join:
* ```typescript
* const tree = new Quadtree({ width: 100, height: 100 });
* tree.split();
* console.log(tree.nodes.length); // 4
* tree.join();
* console.log(tree.nodes.length); // 0
* ```
*
* @returns The objects from this node and all subnodes combined.
*/
join() {
// recursive join
let allObjects = Array.from(this.objects);
for (let i = 0; i < this.nodes.length; i++) {
const tmp = this.nodes[i].join();
allObjects = allObjects.concat(tmp);
}
// remove duplicates
const uniqueObjects = Array.from(new Set(allObjects));
// join if maxObjects and minLevels allow it
if (uniqueObjects.length <= this.maxObjects && this.level >= this.minLevels) {
this.objects = uniqueObjects;
for (let i = 0; i < this.nodes.length; i++) {
this.nodes[i].objects = [];
}
this.nodes = [];
}
return allObjects;
}
/**
* Clear the Quadtree.
*
* @example
* ```typescript
* const tree = new Quadtree({ width: 100, height: 100 });
* tree.insert(new Circle({ x: 25, y: 25, r: 10 }));
* tree.clear();
* console.log(tree); // tree.objects and tree.nodes are empty
* ```
*/
clear() {
this.objects = [];
for (let i = 0; i < this.nodes.length; i++) {
if (this.nodes.length) {
this.nodes[i].clear();
}
}
if (this.level >= this.minLevels) {
this.nodes = [];
}
}
}
/**
* Class representing a Rectangle
* @typeParam CustomDataType - Type of the custom data property (optional, inferred automatically).
*
* @example Without custom data
* ```typescript
* const rectangle = new Rectangle({
* x: 10,
* y: 20,
* width: 30,
* height: 40,
* });
* ```
*
* @example With custom data
* ```javascript
* const rectangle = new Rectangle({
* x: 10,
* y: 20,
* width: 30,
* height: 40,
* data: {
* name: 'Jane',
* health: 100,
* },
* });
* ```
*
* @example With custom data (TS)
* ```typescript
* interface ObjectData {
* name: string;
* health: number;
* }
* const entity: ObjectData = {
* name: 'Jane',
* health: 100,
* };
*
* // Typescript will infer the type of the data property
* const rectangle1 = new Rectangle({
* x: 10,
* y: 20,
* width: 30,
* height: 40,
* data: entity,
* });
*
* // You can also pass in a generic type for the data property
* const rectangle2 = new Rectangle<ObjectData>({
* x: 10,
* y: 20,
* width: 30,
* height: 40,
* });
* rectangle2.data = entity;
* ```
*
* @example With custom class extending Rectangle
* ```javascript
* // extending inherits the geometry's properties and the qtIndex method
* class Box extends Rectangle {
*
* constructor(content, x, y, width, height) {
* super({ x, y, width, height });
* this.content = content;
* }
* }
*
* const box = new Box('Gravity Boots', 10, 20, 30, 40);
* ```
*
* @example With custom class and mapping
* ```javascript
* // no need to extend if you don't implement RectangleGeometry
* class Box {
*
* constructor(content) {
* this.content = content;
* this.position = [10, 20];
* this.size = [30, 40];
* }
*
* // add a qtIndex method to your class
* qtIndex(node) {
* // map your properties to RectangleGeometry
* return Rectangle.prototype.qtIndex.call({
* x: this.position[0],
* y: this.position[1],
* width: this.size[0],
* height: this.size[1],
* }, node);
* }
* }
*
* const box = new Box('Gravity Boots');
* ```
*
* @example With custom object (implements RectangleGeometry)
* ```javascript
* const player = {
* health: 100,
* x: 10,
* y: 20,
* width: 30,
* height: 30,
* qtIndex: Rectangle.prototype.qtIndex,
* });
* ```
*
* @example With custom object and mapping
* ```javascript
* // Note: this is not recommended but possible.
* // Using this technique, each object would have it's own qtIndex method.
* // Rather add qtIndex to your prototype, e.g. by using classes like shown above.
* const player = {
* name: 'Jane',
* health: 100,
* position: [10, 20],
* size: [30, 40],
* qtIndex: function(node) {
* return Rectangle.prototype.qtIndex.call({
* x: this.position[0],
* y: this.position[1],
* width: this.size[0],
* height: this.size[1],
* }, node);
* },
* });
* ```
*/
class Rectangle {
constructor(props) {
this.x = props.x;
this.y = props.y;
this.width = props.width;
this.height = props.height;
this.data = props.data;
}
/**
* Determine which quadrant this rectangle belongs to.
* @param node - Quadtree node to be checked
* @returns Array containing indexes of intersecting subnodes (0-3 = top-right, top-left, bottom-left, bottom-right)
*/
qtIndex(node) {
const indexes = [], boundsCenterX = node.x + node.width / 2, boundsCenterY = node.y + node.height / 2;
const startIsNorth = this.y < boundsCenterY, startIsWest = this.x < boundsCenterX, endIsEast = this.x + this.width > boundsCenterX, endIsSouth = this.y + this.height > boundsCenterY;
// top-right quad
if (startIsNorth && endIsEast) {
indexes.push(0);
}
// top-left quad
if (startIsWest && startIsNorth) {
indexes.push(1);
}
// bottom-left quad
if (startIsWest && endIsSouth) {
indexes.push(2);
}
// bottom-right quad
if (endIsEast && endIsSouth) {
indexes.push(3);
}
return indexes;
}
}
/**
* Class representing a Circle.
* @typeParam CustomDataType - Type of the custom data property (optional, inferred automatically).
*
* @example Without custom data
* ```typescript
* const circle = new Circle({
* x: 100,
* y: 100,
* r: 32,
* });
* ```
*
* @example With custom data
* ```javascript
* const circle = new Circle({
* x: 100,
* y: 100,
* r: 32,
* data: {
* name: 'Jane',
* health: 100,
* },
* });
* ```
*
* @example With custom data (TS)
* ```typescript
* interface ObjectData {
* name: string;
* health: number;
* }
* const entity: ObjectData = {
* name: 'Jane',
* health: 100,
* };
*
* // Typescript will infer the type of the data property
* const circle1 = new Circle({
* x: 100,
* y: 100,
* r: 32,
* data: entity,
* });
*
* // You can also pass in a generic type for the data property
* const circle2 = new Circle<ObjectData>({
* x: 100,
* y: 100,
* r: 32,
* });
* circle2.data = entity;
* ```
*
* @example With custom class extending Circle
* ```javascript
* // extending inherits the geometry's properties and the qtIndex method
* class Bomb extends Circle {
*
* constructor(countdown, x, y, r) {
* super({ x, y, r });
* this.countdown = countdown;
* }
* }
*
* const bomb = new Bomb(5, 10, 20, 30);
* ```
*
* @example With custom class and mapping
* ```javascript
* // no need to extend if you don't implement CircleGeometry
* class Bomb {
*
* constructor(countdown) {
* this.countdown = countdown;
* this.position = [10, 20];
* this.radius = 30;
* }
*
* // add a qtIndex method to your class
* qtIndex(node) {
* // map your properties to CircleGeometry
* return Circle.prototype.qtIndex.call({
* x: this.position[0],
* y: this.position[1],
* r: this.radius,
* }, node);
* }
* }
*
* const bomb = new Bomb(5);
* ```
*
* @example With custom object (implements CircleGeometry)
* ```javascript
* const player = {
* health: 100,
* x: 10,
* y: 20,
* r: 30,
* qtIndex: Circle.prototype.qtIndex,
* });
* ```
*
* @example With custom object and mapping
* ```javascript
* // Note: this is not recommended but possible.
* // Using this technique, each object would have it's own qtIndex method.
* // Rather add qtIndex to your prototype, e.g. by using classes like shown above.
* const player = {
* name: 'Jane',
* health: 100,
* position: [10, 20],
* radius: 30,
* qtIndex: function(node) {
* return Circle.prototype.qtIndex.call({
* x: this.position[0],
* y: this.position[1],
* r: this.radius,
* }, node);
* },
* });
* ```
*/
class Circle {
/**
* Circle Constructor
* @param props - Circle properties
* @typeParam CustomDataType - Type of the custom data property (optional, inferred automatically).
*/
constructor(props) {
this.x = props.x;
this.y = props.y;
this.r = props.r;
this.data = props.data;
}
/**
* Determine which quadrant this circle belongs to.
* @param node - Quadtree node to be checked
* @returns Array containing indexes of intersecting subnodes (0-3 = top-right, top-left, bottom-left, bottom-right)
*/
qtIndex(node) {
const indexes = [], w2 = node.width / 2, h2 = node.height / 2, x2 = node.x + w2, y2 = node.y + h2;
// an array of node origins where the array index equals the node index
const nodes = [
[x2, node.y],
[node.x, node.y],
[node.x, y2],
[x2, y2],
];
// test all nodes for circle intersections
for (let i = 0; i < nodes.length; i++) {
if (Circle.intersectRect(this.x, this.y, this.r, nodes[i][0], nodes[i][1], nodes[i][0] + w2, nodes[i][1] + h2)) {
indexes.push(i);
}
}
return indexes;
}
/**
* Check if a circle intersects an axis aligned rectangle.
* @beta
* @see https://yal.cc/rectangle-circle-intersection-test/
* @param x - circle center X
* @param y - circle center Y
* @param r - circle radius
* @param minX - rectangle start X
* @param minY - rectangle start Y
* @param maxX - rectangle end X
* @param maxY - rectangle end Y
* @returns true if circle intersects rectangle
*
* @example Check if a circle intersects a rectangle:
* ```javascript
* const circ = { x: 10, y: 20, r: 30 };
* const rect = { x: 40, y: 50, width: 60, height: 70 };
* const intersect = Circle.intersectRect(
* circ.x,
* circ.y,
* circ.r,
* rect.x,
* rect.y,
* rect.x + rect.width,
* rect.y + rect.height,
* );
* console.log(circle, rect, 'intersect?', intersect);
* ```
*/
static intersectRect(x, y, r, minX, minY, maxX, maxY) {
const deltaX = x - Math.max(minX, Math.min(x, maxX));
const deltaY = y - Math.max(minY, Math.min(y, maxY));
return deltaX * deltaX + deltaY * deltaY < r * r;
}
}
/**
* Class representing a Line
* @typeParam CustomDataType - Type of the custom data property (optional, inferred automatically).
*
* @example Without custom data
* ```typescript
* const line = new Line({
* x1: 10,
* y1: 20,
* x2: 30,
* y2: 40,
* });
* ```
*
* @example With custom data
* ```javascript
* const line = new Line({
* x1: 10,
* y1: 20,
* x2: 30,
* y2: 40,
* data: {
* name: 'Jane',
* health: 100,
* },
* });
* ```
*
* @example With custom data (TS)
* ```typescript
* interface ObjectData {
* name: string;
* health: number;
* }
* const entity: ObjectData = {
* name: 'Jane',
* health: 100,
* };
*
* // Typescript will infer the type of the data property
* const line1 = new Line({
* x1: 10,
* y1: 20,
* x2: 30,
* y2: 40,
* data: entity,
* });
*
* // You can also pass in a generic type for the data property
* const line2 = new Line<ObjectData>({
* x1: 10,
* y1: 20,
* x2: 30,
* y2: 40,
* });
* line2.data = entity;
* ```
*
* @example With custom class extending Line
* ```javascript
* // extending inherits the geometry's properties and the qtIndex method
* class Laser extends Line {
*
* constructor(color, x1, y1, x2, y2) {
* super({ x1, y1, x2, y2 });
* this.color = color;
* }
* }
*
* const laser = new Laser('green', 10, 20, 30, 40);
* ```
*
* @example With custom class and mapping
* ```javascript
* // no need to extend if you don't implement LineGeometry
* class Laser {
*
* constructor(color) {
* this.color = color;
* this.start = [10, 20];
* this.end = [30, 40];
* }
*
* // add a qtIndex method to your class
* qtIndex(node) {
* // map your properties to LineGeometry
* return Line.prototype.qtIndex.call({
* x1: this.start[0],
* y1: this.start[1],
* x2: this.end[0],
* y2: this.end[1],
* }, node);
* }
* }
*
* const laser = new Laser('green');
* ```
*
* @example With custom object (implements LineGeometry)
* ```javascript
* const player = {
* health: 100,
* x1: 10,
* y1: 20,
* x2: 30,
* y2: 40,
* qtIndex: Line.prototype.qtIndex,
* });
* ```
*
* @example With custom object and mapping
* ```javascript
* // Note: this is not recommended but possible.
* // Using this technique, each object would have it's own qtIndex method.
* // Rather add qtIndex to your prototype, e.g. by using classes like shown above.
* const player = {
* name: 'Jane',
* health: 100,
* start: [10, 20],
* end: [30, 40],
* qtIndex: function(node) {
* return Line.prototype.qtIndex.call({
* x1: this.start[0],
* y1: this.start[1],
* x2: this.end[0],
* y2: this.end[1],
* }, node);
* },
* });
* ```
*/
class Line {
/**
* Line Constructor
* @param props - Line properties
* @typeParam CustomDataType - Type of the custom data property (optional, inferred automatically).
*/
constructor(props) {
this.x1 = props.x1;
this.y1 = props.y1;
this.x2 = props.x2;
this.y2 = props.y2;
this.data = props.data;
}
/**
* Determine which quadrant this line belongs to.
* @beta
* @param node - Quadtree node to be checked
* @returns Array containing indexes of intersecting subnodes (0-3 = top-right, top-left, bottom-left, bottom-right)
*/
qtIndex(node) {
const indexes = [], w2 = node.width / 2, h2 = node.height / 2, x2 = node.x + w2, y2 = node.y + h2;
// an array of node origins where the array index equals the node index
const nodes = [
[x2, node.y],
[node.x, node.y],
[node.x, y2],
[x2, y2],
];
// test all nodes for line intersections
for (let i = 0; i < nodes.length; i++) {
if (Line.intersectRect(this.x1, this.y1, this.x2, this.y2, nodes[i][0], nodes[i][1], nodes[i][0] + w2, nodes[i][1] + h2)) {
indexes.push(i);
}
}
return indexes;
}
/**
* check if a line segment (the first 4 parameters) intersects an axis aligned rectangle (the last 4 parameters)
* @beta
*
* @remarks
* There is a bug where detection fails on corner intersections
* when the line enters/exits the node exactly at corners (45°)
* {@link https://stackoverflow.com/a/18292964/860205}
*
* @param x1 - line start X
* @param y1 - line start Y
* @param x2 - line end X
* @param y2 - line end Y
* @param minX - rectangle start X
* @param minY - rectangle start Y
* @param maxX - rectangle end X
* @param maxY - rectangle end Y
* @returns true if the line segment intersects the axis aligned rectangle
*/
static intersectRect(x1, y1, x2, y2, minX, minY, maxX, maxY) {
// Completely outside
if ((x1 <= minX && x2 <= minX) ||
(y1 <= minY && y2 <= minY) ||
(x1 >= maxX && x2 >= maxX) ||
(y1 >= maxY && y2 >= maxY))
return false;
// Single point inside
if ((x1 >= minX && x1 <= maxX && y1 >= minY && y1 <= maxY) ||
(x2 >= minX && x2 <= maxX && y2 >= minY && y2 <= maxY))
return true;
const m = (y2 - y1) / (x2 - x1);
let y = m * (minX - x1) + y1;
if (y > minY && y < maxY)
return true;
y = m * (maxX - x1) + y1;
if (y > minY && y < maxY)
return true;
let x = (minY - y1) / m + x1;
if (x > minX && x < maxX)
return true;
x = (maxY - y1) / m + x1;
if (x > minX && x < maxX)
return true;
return false;
}
}
exports.Circle = Circle;
exports.Line = Line;
exports.Quadtree = Quadtree;
exports.Rectangle = Rectangle;