sigma
Version:
A JavaScript library aimed at visualizing graphs of thousands of nodes and edges.
329 lines (328 loc) • 14.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.rectangleCollidesWithQuad = exports.squareCollidesWithQuad = exports.getCircumscribedAlignedRectangle = exports.isRectangleAligned = void 0;
/**
* Sigma.js Quad Tree Class
* =========================
*
* Class implementing the quad tree data structure used to solve hovers and
* determine which elements are currently in the scope of the camera so that
* we don't waste time rendering things the user cannot see anyway.
* @module
*/
/* eslint no-nested-ternary: 0 */
/* eslint no-constant-condition: 0 */
var extend_1 = __importDefault(require("@yomguithereal/helpers/extend"));
/**
* Notes:
*
* - a square can be represented as topleft + width, saying for the quad blocks,
* to reduce overall memory usage (which is already pretty low).
* - this implementation of a quadtree is often called a MX-CIF quadtree.
* - we could explore spatial hashing (hilbert quadtrees, notably).
*/
/**
* Constants.
*
* Note that since we are representing a static 4-ary tree, the indices of the
* quadrants are the following:
* - TOP_LEFT: 4i + b
* - TOP_RIGHT: 4i + 2b
* - BOTTOM_LEFT: 4i + 3b
* - BOTTOM_RIGHT: 4i + 4b
*/
var BLOCKS = 4;
var MAX_LEVEL = 5;
// Outside block is max block index + 1, i.e.:
// BLOCKS * ((4 * (4 ** MAX_LEVEL) - 1) / 3)
var OUTSIDE_BLOCK = 5460;
var X_OFFSET = 0;
var Y_OFFSET = 1;
var WIDTH_OFFSET = 2;
var HEIGHT_OFFSET = 3;
var TOP_LEFT = 1;
var TOP_RIGHT = 2;
var BOTTOM_LEFT = 3;
var BOTTOM_RIGHT = 4;
var hasWarnedTooMuchOutside = false;
/**
* Geometry helpers.
*/
/**
* Function returning whether the given rectangle is axis-aligned.
*
* @param {Rectangle} rect
* @return {boolean}
*/
function isRectangleAligned(rect) {
return rect.x1 === rect.x2 || rect.y1 === rect.y2;
}
exports.isRectangleAligned = isRectangleAligned;
/**
* Function returning the smallest rectangle that contains the given rectangle, and that is aligned with the axis.
*
* @param {Rectangle} rect
* @return {Rectangle}
*/
function getCircumscribedAlignedRectangle(rect) {
var width = Math.sqrt(Math.pow(rect.x2 - rect.x1, 2) + Math.pow(rect.y2 - rect.y1, 2));
var heightVector = {
x: ((rect.y1 - rect.y2) * rect.height) / width,
y: ((rect.x2 - rect.x1) * rect.height) / width,
};
// Compute all corners:
var tl = { x: rect.x1, y: rect.y1 };
var tr = { x: rect.x2, y: rect.y2 };
var bl = {
x: rect.x1 + heightVector.x,
y: rect.y1 + heightVector.y,
};
var br = {
x: rect.x2 + heightVector.x,
y: rect.y2 + heightVector.y,
};
var xL = Math.min(tl.x, tr.x, bl.x, br.x);
var xR = Math.max(tl.x, tr.x, bl.x, br.x);
var yT = Math.min(tl.y, tr.y, bl.y, br.y);
var yB = Math.max(tl.y, tr.y, bl.y, br.y);
return {
x1: xL,
y1: yT,
x2: xR,
y2: yT,
height: yB - yT,
};
}
exports.getCircumscribedAlignedRectangle = getCircumscribedAlignedRectangle;
/**
*
* @param x1
* @param y1
* @param w
* @param qx
* @param qy
* @param qw
* @param qh
*/
function squareCollidesWithQuad(x1, y1, w, qx, qy, qw, qh) {
return x1 < qx + qw && x1 + w > qx && y1 < qy + qh && y1 + w > qy;
}
exports.squareCollidesWithQuad = squareCollidesWithQuad;
function rectangleCollidesWithQuad(x1, y1, w, h, qx, qy, qw, qh) {
return x1 < qx + qw && x1 + w > qx && y1 < qy + qh && y1 + h > qy;
}
exports.rectangleCollidesWithQuad = rectangleCollidesWithQuad;
function pointIsInQuad(x, y, qx, qy, qw, qh) {
var xmp = qx + qw / 2, ymp = qy + qh / 2, top = y < ymp, left = x < xmp;
return top ? (left ? TOP_LEFT : TOP_RIGHT) : left ? BOTTOM_LEFT : BOTTOM_RIGHT;
}
/**
* Helper functions that are not bound to the class so an external user
* cannot mess with them.
*/
function buildQuadrants(maxLevel, data) {
// [block, level]
var stack = [0, 0];
while (stack.length) {
var level = stack.pop(), block = stack.pop();
var topLeftBlock = 4 * block + BLOCKS, topRightBlock = 4 * block + 2 * BLOCKS, bottomLeftBlock = 4 * block + 3 * BLOCKS, bottomRightBlock = 4 * block + 4 * BLOCKS;
var x = data[block + X_OFFSET], y = data[block + Y_OFFSET], width = data[block + WIDTH_OFFSET], height = data[block + HEIGHT_OFFSET], hw = width / 2, hh = height / 2;
data[topLeftBlock + X_OFFSET] = x;
data[topLeftBlock + Y_OFFSET] = y;
data[topLeftBlock + WIDTH_OFFSET] = hw;
data[topLeftBlock + HEIGHT_OFFSET] = hh;
data[topRightBlock + X_OFFSET] = x + hw;
data[topRightBlock + Y_OFFSET] = y;
data[topRightBlock + WIDTH_OFFSET] = hw;
data[topRightBlock + HEIGHT_OFFSET] = hh;
data[bottomLeftBlock + X_OFFSET] = x;
data[bottomLeftBlock + Y_OFFSET] = y + hh;
data[bottomLeftBlock + WIDTH_OFFSET] = hw;
data[bottomLeftBlock + HEIGHT_OFFSET] = hh;
data[bottomRightBlock + X_OFFSET] = x + hw;
data[bottomRightBlock + Y_OFFSET] = y + hh;
data[bottomRightBlock + WIDTH_OFFSET] = hw;
data[bottomRightBlock + HEIGHT_OFFSET] = hh;
if (level < maxLevel - 1) {
stack.push(bottomRightBlock, level + 1);
stack.push(bottomLeftBlock, level + 1);
stack.push(topRightBlock, level + 1);
stack.push(topLeftBlock, level + 1);
}
}
}
function insertNode(maxLevel, data, containers, key, x, y, size) {
var x1 = x - size, y1 = y - size, w = size * 2;
var level = 0, block = 0;
while (true) {
// If we reached max level
if (level >= maxLevel) {
containers[block] = containers[block] || [];
containers[block].push(key);
return;
}
var topLeftBlock = 4 * block + BLOCKS, topRightBlock = 4 * block + 2 * BLOCKS, bottomLeftBlock = 4 * block + 3 * BLOCKS, bottomRightBlock = 4 * block + 4 * BLOCKS;
var collidingWithTopLeft = squareCollidesWithQuad(x1, y1, w, data[topLeftBlock + X_OFFSET], data[topLeftBlock + Y_OFFSET], data[topLeftBlock + WIDTH_OFFSET], data[topLeftBlock + HEIGHT_OFFSET]);
var collidingWithTopRight = squareCollidesWithQuad(x1, y1, w, data[topRightBlock + X_OFFSET], data[topRightBlock + Y_OFFSET], data[topRightBlock + WIDTH_OFFSET], data[topRightBlock + HEIGHT_OFFSET]);
var collidingWithBottomLeft = squareCollidesWithQuad(x1, y1, w, data[bottomLeftBlock + X_OFFSET], data[bottomLeftBlock + Y_OFFSET], data[bottomLeftBlock + WIDTH_OFFSET], data[bottomLeftBlock + HEIGHT_OFFSET]);
var collidingWithBottomRight = squareCollidesWithQuad(x1, y1, w, data[bottomRightBlock + X_OFFSET], data[bottomRightBlock + Y_OFFSET], data[bottomRightBlock + WIDTH_OFFSET], data[bottomRightBlock + HEIGHT_OFFSET]);
var collisions = [
collidingWithTopLeft,
collidingWithTopRight,
collidingWithBottomLeft,
collidingWithBottomRight,
].reduce(function (acc, current) {
if (current)
return acc + 1;
else
return acc;
}, 0);
// If we have no collision at root level, inject node in the outside block
if (collisions === 0 && level === 0) {
containers[OUTSIDE_BLOCK].push(key);
if (!hasWarnedTooMuchOutside && containers[OUTSIDE_BLOCK].length >= 5) {
hasWarnedTooMuchOutside = true;
console.warn("sigma/quadtree.insertNode: At least 5 nodes are outside the global quadtree zone. " +
"You might have a problem with the normalization function or the custom bounding box.");
}
return;
}
// If we don't have at least a collision but deeper, there is an issue
if (collisions === 0)
throw new Error("sigma/quadtree.insertNode: no collision (level: ".concat(level, ", key: ").concat(key, ", x: ").concat(x, ", y: ").concat(y, ", size: ").concat(size, ")."));
// If we have 3 collisions, we have a geometry problem obviously
if (collisions === 3)
throw new Error("sigma/quadtree.insertNode: 3 impossible collisions (level: ".concat(level, ", key: ").concat(key, ", x: ").concat(x, ", y: ").concat(y, ", size: ").concat(size, ")."));
// If we have more that one collision, we stop here and store the node
// in the relevant containers
if (collisions > 1) {
containers[block] = containers[block] || [];
containers[block].push(key);
return;
}
else {
level++;
}
// Else we recurse into the correct quads
if (collidingWithTopLeft)
block = topLeftBlock;
if (collidingWithTopRight)
block = topRightBlock;
if (collidingWithBottomLeft)
block = bottomLeftBlock;
if (collidingWithBottomRight)
block = bottomRightBlock;
}
}
function getNodesInAxisAlignedRectangleArea(maxLevel, data, containers, x1, y1, w, h) {
// [block, level]
var stack = [0, 0];
var collectedNodes = [];
var container;
while (stack.length) {
var level = stack.pop(), block = stack.pop();
// Collecting nodes
container = containers[block];
if (container)
(0, extend_1.default)(collectedNodes, container);
// If we reached max level
if (level >= maxLevel)
continue;
var topLeftBlock = 4 * block + BLOCKS, topRightBlock = 4 * block + 2 * BLOCKS, bottomLeftBlock = 4 * block + 3 * BLOCKS, bottomRightBlock = 4 * block + 4 * BLOCKS;
var collidingWithTopLeft = rectangleCollidesWithQuad(x1, y1, w, h, data[topLeftBlock + X_OFFSET], data[topLeftBlock + Y_OFFSET], data[topLeftBlock + WIDTH_OFFSET], data[topLeftBlock + HEIGHT_OFFSET]);
var collidingWithTopRight = rectangleCollidesWithQuad(x1, y1, w, h, data[topRightBlock + X_OFFSET], data[topRightBlock + Y_OFFSET], data[topRightBlock + WIDTH_OFFSET], data[topRightBlock + HEIGHT_OFFSET]);
var collidingWithBottomLeft = rectangleCollidesWithQuad(x1, y1, w, h, data[bottomLeftBlock + X_OFFSET], data[bottomLeftBlock + Y_OFFSET], data[bottomLeftBlock + WIDTH_OFFSET], data[bottomLeftBlock + HEIGHT_OFFSET]);
var collidingWithBottomRight = rectangleCollidesWithQuad(x1, y1, w, h, data[bottomRightBlock + X_OFFSET], data[bottomRightBlock + Y_OFFSET], data[bottomRightBlock + WIDTH_OFFSET], data[bottomRightBlock + HEIGHT_OFFSET]);
if (collidingWithTopLeft)
stack.push(topLeftBlock, level + 1);
if (collidingWithTopRight)
stack.push(topRightBlock, level + 1);
if (collidingWithBottomLeft)
stack.push(bottomLeftBlock, level + 1);
if (collidingWithBottomRight)
stack.push(bottomRightBlock, level + 1);
}
return collectedNodes;
}
/**
* QuadTree class.
*
* @constructor
* @param {object} boundaries - The graph boundaries.
*/
var QuadTree = /** @class */ (function () {
function QuadTree(params) {
var _a;
if (params === void 0) { params = {}; }
this.containers = (_a = {}, _a[OUTSIDE_BLOCK] = [], _a);
this.cache = null;
this.lastRectangle = null;
// Allocating the underlying byte array
var L = Math.pow(4, MAX_LEVEL);
this.data = new Float32Array(BLOCKS * ((4 * L - 1) / 3));
if (params.boundaries)
this.resize(params.boundaries);
else
this.resize({
x: 0,
y: 0,
width: 1,
height: 1,
});
}
QuadTree.prototype.add = function (key, x, y, size) {
insertNode(MAX_LEVEL, this.data, this.containers, key, x, y, size);
return this;
};
QuadTree.prototype.resize = function (boundaries) {
this.clear();
// Building the quadrants
this.data[X_OFFSET] = boundaries.x;
this.data[Y_OFFSET] = boundaries.y;
this.data[WIDTH_OFFSET] = boundaries.width;
this.data[HEIGHT_OFFSET] = boundaries.height;
buildQuadrants(MAX_LEVEL, this.data);
};
QuadTree.prototype.clear = function () {
var _a;
this.containers = (_a = {}, _a[OUTSIDE_BLOCK] = [], _a);
return this;
};
QuadTree.prototype.point = function (x, y) {
var nodes = this.containers[OUTSIDE_BLOCK].slice();
var block = 0, level = 0;
do {
if (this.containers[block])
(0, extend_1.default)(nodes, this.containers[block]);
var quad = pointIsInQuad(x, y, this.data[block + X_OFFSET], this.data[block + Y_OFFSET], this.data[block + WIDTH_OFFSET], this.data[block + HEIGHT_OFFSET]);
block = 4 * block + quad * BLOCKS;
level++;
} while (level <= MAX_LEVEL);
return nodes;
};
QuadTree.prototype.rectangle = function (x1, y1, x2, y2, height) {
var lr = this.lastRectangle;
if (lr && x1 === lr.x1 && x2 === lr.x2 && y1 === lr.y1 && y2 === lr.y2 && height === lr.height) {
return this.cache;
}
this.lastRectangle = {
x1: x1,
y1: y1,
x2: x2,
y2: y2,
height: height,
};
// If the rectangle is shifted, we use the smallest aligned rectangle that contains the shifted one:
if (!isRectangleAligned(this.lastRectangle))
this.lastRectangle = getCircumscribedAlignedRectangle(this.lastRectangle);
this.cache = getNodesInAxisAlignedRectangleArea(MAX_LEVEL, this.data, this.containers, x1, y1, Math.abs(x1 - x2) || Math.abs(y1 - y2), height);
// Add all the nodes in the outside block, since they might be relevant, and since they should be very few:
(0, extend_1.default)(this.cache, this.containers[OUTSIDE_BLOCK]);
return this.cache;
};
return QuadTree;
}());
exports.default = QuadTree;