UNPKG

sigma

Version:

A JavaScript library aimed at visualizing graphs of thousands of nodes and edges.

329 lines (328 loc) 14.1 kB
"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;