UNPKG

12.8 kBPlain TextView Raw
1/* eslint no-nested-ternary: 0 */
2/* eslint no-constant-condition: 0 */
3/**
4 * Sigma.js Quad Tree Class
5 * =========================
6 *
7 * Class implementing the quad tree data structure used to solve hovers and
8 * determine which elements are currently in the scope of the camera so that
9 * we don't waste time rendering things the user cannot see anyway.
10 */
11import extend from "@yomguithereal/helpers/extend";
12
13// TODO: should not ask the quadtree when the camera has the whole graph in
14// sight.
15
16// TODO: a square can be represented as topleft + width, saying for the quad blocks (reduce mem)
17
18// TODO: jsdoc
19
20// TODO: be sure we can handle cases overcoming boundaries (because of size) or use a maxed size
21
22// TODO: filtering unwanted labels beforehand through the filter function
23
24// NOTE: this is basically a MX-CIF Quadtree at this point
25// NOTE: need to explore R-Trees for edges
26// NOTE: need to explore 2d segment tree for edges
27
28// NOTE: probably can do faster using spatial hashing
29
30/**
31 * Constants.
32 *
33 * Note that since we are representing a static 4-ary tree, the indices of the
34 * quadrants are the following:
35 * - TOP_LEFT: 4i + b
36 * - TOP_RIGHT: 4i + 2b
37 * - BOTTOM_LEFT: 4i + 3b
38 * - BOTTOM_RIGHT: 4i + 4b
39 */
40const BLOCKS = 4,
41 MAX_LEVEL = 5;
42
43const X_OFFSET = 0,
44 Y_OFFSET = 1,
45 WIDTH_OFFSET = 2,
46 HEIGHT_OFFSET = 3;
47
48const TOP_LEFT = 1,
49 TOP_RIGHT = 2,
50 BOTTOM_LEFT = 3,
51 BOTTOM_RIGHT = 4;
52
53/**
54 * Geometry helpers.
55 */
56
57/**
58 * Function returning whether the given rectangle is axis-aligned.
59 *
60 * @param {number} x1
61 * @param {number} y1
62 * @param {number} x2
63 * @param {number} y2
64 * @return {boolean}
65 */
66function isAxisAligned(x1: number, y1: number, x2: number, y2: number): boolean {
67 return x1 === x2 || y1 === y2;
68}
69
70function squareCollidesWithQuad(
71 x1: number,
72 y1: number,
73 w: number,
74 qx: number,
75 qy: number,
76 qw: number,
77 qh: number,
78): boolean {
79 return x1 < qx + qw && x1 + w > qx && y1 < qy + qh && y1 + w > qy;
80}
81
82function rectangleCollidesWithQuad(
83 x1: number,
84 y1: number,
85 w: number,
86 h: number,
87 qx: number,
88 qy: number,
89 qw: number,
90 qh: number,
91): boolean {
92 return x1 < qx + qw && x1 + w > qx && y1 < qy + qh && y1 + h > qy;
93}
94
95function pointIsInQuad(x: number, y: number, qx: number, qy: number, qw: number, qh: number): number {
96 const xmp = qx + qw / 2,
97 ymp = qy + qh / 2,
98 top = y < ymp,
99 left = x < xmp;
100
101 return top ? (left ? TOP_LEFT : TOP_RIGHT) : left ? BOTTOM_LEFT : BOTTOM_RIGHT;
102}
103
104/**
105 * Helper functions that are not bound to the class so an external user
106 * cannot mess with them.
107 */
108function buildQuadrants(maxLevel, data) {
109 // [block, level]
110 const stack = [0, 0];
111
112 while (stack.length) {
113 const level = stack.pop(),
114 block = stack.pop();
115
116 const topLeftBlock = 4 * block + BLOCKS,
117 topRightBlock = 4 * block + 2 * BLOCKS,
118 bottomLeftBlock = 4 * block + 3 * BLOCKS,
119 bottomRightBlock = 4 * block + 4 * BLOCKS;
120
121 const x = data[block + X_OFFSET],
122 y = data[block + Y_OFFSET],
123 width = data[block + WIDTH_OFFSET],
124 height = data[block + HEIGHT_OFFSET],
125 hw = width / 2,
126 hh = height / 2;
127
128 data[topLeftBlock + X_OFFSET] = x;
129 data[topLeftBlock + Y_OFFSET] = y;
130 data[topLeftBlock + WIDTH_OFFSET] = hw;
131 data[topLeftBlock + HEIGHT_OFFSET] = hh;
132
133 data[topRightBlock + X_OFFSET] = x + hw;
134 data[topRightBlock + Y_OFFSET] = y;
135 data[topRightBlock + WIDTH_OFFSET] = hw;
136 data[topRightBlock + HEIGHT_OFFSET] = hh;
137
138 data[bottomLeftBlock + X_OFFSET] = x;
139 data[bottomLeftBlock + Y_OFFSET] = y + hh;
140 data[bottomLeftBlock + WIDTH_OFFSET] = hw;
141 data[bottomLeftBlock + HEIGHT_OFFSET] = hh;
142
143 data[bottomRightBlock + X_OFFSET] = x + hw;
144 data[bottomRightBlock + Y_OFFSET] = y + hh;
145 data[bottomRightBlock + WIDTH_OFFSET] = hw;
146 data[bottomRightBlock + HEIGHT_OFFSET] = hh;
147
148 if (level < maxLevel - 1) {
149 stack.push(bottomRightBlock, level + 1);
150 stack.push(bottomLeftBlock, level + 1);
151 stack.push(topRightBlock, level + 1);
152 stack.push(topLeftBlock, level + 1);
153 }
154 }
155}
156
157function insertNode(maxLevel, data, containers, key, x, y, size) {
158 const x1 = x - size,
159 y1 = y - size,
160 w = size * 2;
161
162 let level = 0,
163 block = 0;
164
165 while (true) {
166 // If we reached max level
167 if (level >= maxLevel) {
168 containers[block] = containers[block] || [];
169 containers[block].push(key);
170 return;
171 }
172
173 const topLeftBlock = 4 * block + BLOCKS,
174 topRightBlock = 4 * block + 2 * BLOCKS,
175 bottomLeftBlock = 4 * block + 3 * BLOCKS,
176 bottomRightBlock = 4 * block + 4 * BLOCKS;
177
178 const collidingWithTopLeft = squareCollidesWithQuad(
179 x1,
180 y1,
181 w,
182 data[topLeftBlock + X_OFFSET],
183 data[topLeftBlock + Y_OFFSET],
184 data[topLeftBlock + WIDTH_OFFSET],
185 data[topLeftBlock + HEIGHT_OFFSET],
186 );
187
188 const collidingWithTopRight = squareCollidesWithQuad(
189 x1,
190 y1,
191 w,
192 data[topRightBlock + X_OFFSET],
193 data[topRightBlock + Y_OFFSET],
194 data[topRightBlock + WIDTH_OFFSET],
195 data[topRightBlock + HEIGHT_OFFSET],
196 );
197
198 const collidingWithBottomLeft = squareCollidesWithQuad(
199 x1,
200 y1,
201 w,
202 data[bottomLeftBlock + X_OFFSET],
203 data[bottomLeftBlock + Y_OFFSET],
204 data[bottomLeftBlock + WIDTH_OFFSET],
205 data[bottomLeftBlock + HEIGHT_OFFSET],
206 );
207
208 const collidingWithBottomRight = squareCollidesWithQuad(
209 x1,
210 y1,
211 w,
212 data[bottomRightBlock + X_OFFSET],
213 data[bottomRightBlock + Y_OFFSET],
214 data[bottomRightBlock + WIDTH_OFFSET],
215 data[bottomRightBlock + HEIGHT_OFFSET],
216 );
217
218 const collisions: number = [
219 collidingWithTopLeft,
220 collidingWithTopRight,
221 collidingWithBottomLeft,
222 collidingWithBottomRight,
223 ].reduce((acc: number, current: boolean) => {
224 if (current) return acc + 1;
225 else return acc;
226 }, 0);
227
228 // If we don't have at least a collision, there is an issue
229 if (collisions === 0)
230 throw new Error(
231 `sigma/quadtree.insertNode: no collision (level: ${level}, key: ${key}, x: ${x}, y: ${y}, size: ${size}).`,
232 );
233
234 // If we have 3 collisions, we have a geometry problem obviously
235 if (collisions === 3)
236 throw new Error(
237 `sigma/quadtree.insertNode: 3 impossible collisions (level: ${level}, key: ${key}, x: ${x}, y: ${y}, size: ${size}).`,
238 );
239
240 // If we have more that one collision, we stop here and store the node
241 // in the relevant containers
242 if (collisions > 1) {
243 // NOTE: this is a nice way to optimize for hover, but not for frustum
244 // since it requires to uniq the collected nodes
245 // if (collisions < 4) {
246
247 // // If we intersect two quads, we place the node in those two
248 // if (collidingWithTopLeft) {
249 // containers[topLeftBlock] = containers[topLeftBlock] || [];
250 // containers[topLeftBlock].push(key);
251 // }
252 // if (collidingWithTopRight) {
253 // containers[topRightBlock] = containers[topRightBlock] || [];
254 // containers[topRightBlock].push(key);
255 // }
256 // if (collidingWithBottomLeft) {
257 // containers[bottomLeftBlock] = containers[bottomLeftBlock] || [];
258 // containers[bottomLeftBlock].push(key);
259 // }
260 // if (collidingWithBottomRight) {
261 // containers[bottomRightBlock] = containers[bottomRightBlock] || [];
262 // containers[bottomRightBlock].push(key);
263 // }
264 // }
265 // else {
266
267 // // Else we keep the node where it is to avoid more pointless computations
268 // containers[block] = containers[block] || [];
269 // containers[block].push(key);
270 // }
271
272 containers[block] = containers[block] || [];
273 containers[block].push(key);
274
275 return;
276 } else {
277 level++;
278 }
279
280 // Else we recurse into the correct quads
281 if (collidingWithTopLeft) block = topLeftBlock;
282
283 if (collidingWithTopRight) block = topRightBlock;
284
285 if (collidingWithBottomLeft) block = bottomLeftBlock;
286
287 if (collidingWithBottomRight) block = bottomRightBlock;
288 }
289}
290
291function getNodesInAxisAlignedRectangleArea(maxLevel, data, containers, x1, y1, w, h) {
292 // [block, level]
293 const stack = [0, 0];
294
295 const collectedNodes = [];
296
297 let container;
298
299 while (stack.length) {
300 const level = stack.pop(),
301 block = stack.pop();
302
303 // Collecting nodes
304 container = containers[block];
305
306 if (container) extend(collectedNodes, container);
307
308 // If we reached max level
309 if (level >= maxLevel) continue;
310
311 const topLeftBlock = 4 * block + BLOCKS,
312 topRightBlock = 4 * block + 2 * BLOCKS,
313 bottomLeftBlock = 4 * block + 3 * BLOCKS,
314 bottomRightBlock = 4 * block + 4 * BLOCKS;
315
316 const collidingWithTopLeft = rectangleCollidesWithQuad(
317 x1,
318 y1,
319 w,
320 h,
321 data[topLeftBlock + X_OFFSET],
322 data[topLeftBlock + Y_OFFSET],
323 data[topLeftBlock + WIDTH_OFFSET],
324 data[topLeftBlock + HEIGHT_OFFSET],
325 );
326
327 const collidingWithTopRight = rectangleCollidesWithQuad(
328 x1,
329 y1,
330 w,
331 h,
332 data[topRightBlock + X_OFFSET],
333 data[topRightBlock + Y_OFFSET],
334 data[topRightBlock + WIDTH_OFFSET],
335 data[topRightBlock + HEIGHT_OFFSET],
336 );
337
338 const collidingWithBottomLeft = rectangleCollidesWithQuad(
339 x1,
340 y1,
341 w,
342 h,
343 data[bottomLeftBlock + X_OFFSET],
344 data[bottomLeftBlock + Y_OFFSET],
345 data[bottomLeftBlock + WIDTH_OFFSET],
346 data[bottomLeftBlock + HEIGHT_OFFSET],
347 );
348
349 const collidingWithBottomRight = rectangleCollidesWithQuad(
350 x1,
351 y1,
352 w,
353 h,
354 data[bottomRightBlock + X_OFFSET],
355 data[bottomRightBlock + Y_OFFSET],
356 data[bottomRightBlock + WIDTH_OFFSET],
357 data[bottomRightBlock + HEIGHT_OFFSET],
358 );
359
360 if (collidingWithTopLeft) stack.push(topLeftBlock, level + 1);
361
362 if (collidingWithTopRight) stack.push(topRightBlock, level + 1);
363
364 if (collidingWithBottomLeft) stack.push(bottomLeftBlock, level + 1);
365
366 if (collidingWithBottomRight) stack.push(bottomRightBlock, level + 1);
367 }
368
369 return collectedNodes;
370}
371
372/**
373 * QuadTree class.
374 *
375 * @constructor
376 * @param {object} boundaries - The graph boundaries.
377 */
378export default class QuadTree {
379 data: Float32Array;
380 containers: any = {};
381 cache: any = null;
382 lastRectangle: any = null;
383 nodeFilter: any;
384
385 constructor(params: { [key: string]: any } = {}) {
386 // Allocating the underlying byte array
387 const L = Math.pow(4, MAX_LEVEL);
388 this.data = new Float32Array(BLOCKS * ((4 * L - 1) / 3));
389
390 if (params.boundaries) this.resize(params.boundaries);
391 else
392 this.resize({
393 x: 0,
394 y: 0,
395 width: 1,
396 height: 1,
397 });
398
399 if (typeof params.filter === "function") this.nodeFilter = params.filter;
400 }
401
402 add(key: any, x: number, y: number, size: number) {
403 insertNode(MAX_LEVEL, this.data, this.containers, key, x, y, size);
404
405 return this;
406 }
407
408 resize(boundaries: { x: number; y: number; width: number; height: number }) {
409 this.clear();
410
411 // Building the quadrants
412 this.data[X_OFFSET] = boundaries.x;
413 this.data[Y_OFFSET] = boundaries.y;
414 this.data[WIDTH_OFFSET] = boundaries.width;
415 this.data[HEIGHT_OFFSET] = boundaries.height;
416
417 buildQuadrants(MAX_LEVEL, this.data);
418 }
419
420 clear() {
421 this.containers = {};
422
423 return this;
424 }
425
426 point(x: number, y: number) {
427 const nodes = [];
428
429 let block = 0,
430 level = 0;
431
432 do {
433 if (this.containers[block]) nodes.push.apply(nodes, this.containers[block]);
434
435 const quad = pointIsInQuad(
436 x,
437 y,
438 this.data[block + X_OFFSET],
439 this.data[block + Y_OFFSET],
440 this.data[block + WIDTH_OFFSET],
441 this.data[block + HEIGHT_OFFSET],
442 );
443
444 block = 4 * block + quad * BLOCKS;
445 level++;
446 } while (level <= MAX_LEVEL);
447
448 return nodes;
449 }
450
451 rectangle(x1: number, y1: number, x2: number, y2: number, height: number) {
452 const lr = this.lastRectangle;
453
454 if (lr && x1 === lr.x1 && x2 === lr.x2 && y1 === lr.y1 && y2 === lr.y2 && height === lr.height) {
455 return this.cache;
456 }
457
458 this.lastRectangle = {
459 x1,
460 y1,
461 x2,
462 y2,
463 height,
464 };
465
466 // Is the rectangle axis aligned?
467 if (!isAxisAligned(x1, y1, x2, y2))
468 throw new Error("sigma/quadtree.rectangle: shifted view is not yet implemented.");
469
470 const collectedNodes = getNodesInAxisAlignedRectangleArea(
471 MAX_LEVEL,
472 this.data,
473 this.containers,
474 x1,
475 y1,
476 Math.abs(x1 - x2) || Math.abs(y1 - y2),
477 height,
478 );
479
480 this.cache = collectedNodes;
481
482 return this.cache;
483 }
484}