@gravity-ui/graph
Version:
Modern graph editor component
354 lines (353 loc) • 12.3 kB
JavaScript
import { signal } from "@preact/signals-core";
import RBush from "rbush";
import { ESchedulerPriority } from "../lib";
import { Emitter } from "../utils/Emitter";
import { noop } from "../utils/functions";
import { debounce } from "../utils/utils/schedule";
import { IncrementalBoundingBoxTracker } from "./IncrementalBoundingBoxTracker";
/**
* Hit Testing system with dual architecture for performance optimization:
*
* 1. interactiveTree (RBush) - for fast spatial search of elements on click/hover
* 2. usableRectTracker (IncrementalBoundingBoxTracker) - for optimized calculation of overall bbox
*
* This solution allows:
* - Avoid circular dependencies with boundary elements (affectsUsableRect: false)
* - Optimize performance for different types of operations
* - Efficiently handle high-frequency updates (drag operations up to 120fps)
*
* Performance benchmark results:
* - Multiple blocks drag: +62% faster than naive approach 🏆
* - Single block drag: equal performance
* - Solution to the problem of recalculating usableRect on every change
*/
export class HitTest extends Emitter {
constructor(graph) {
super();
this.graph = graph;
// RBush tree for interactive elements (spatial search)
this.interactiveTree = new RBush(9);
// Incremental tracker for elements affecting usableRect
this.usableRectTracker = new IncrementalBoundingBoxTracker();
this.$usableRect = signal({ x: 0, y: 0, width: 0, height: 0 });
// Single queue replaces all complex state tracking
this.queue = new Map();
// Optimized debounced processing with incremental updates for drag operations
this.processQueue = debounce(() => {
const interactiveItems = [];
for (const [item, bbox] of this.queue) {
this.interactiveTree.remove(item);
if (bbox) {
let shouldUpdate = true;
if (item.affectsUsableRect) {
if (this.usableRectTracker.has(item)) {
this.usableRectTracker.update(item, bbox);
}
else {
item.updateRect(bbox);
shouldUpdate = false;
this.usableRectTracker.add(item);
}
}
else {
this.usableRectTracker.remove(item);
}
if (shouldUpdate) {
item.updateRect(bbox);
}
interactiveItems.push(item);
}
else {
this.usableRectTracker.remove(item);
}
}
this.interactiveTree.load(interactiveItems);
this.queue.clear();
this.updateUsableRect();
this.emit("update", this);
}, {
priority: ESchedulerPriority.LOWEST,
frameInterval: 1, // run every scheduled lowest frame
});
}
/**
* Check if graph has any elements (blocks or connections)
* @returns true if graph has elements, false if empty
*/
hasGraphElements() {
if (!this.graph) {
return false;
}
return (this.graph.rootStore.blocksList.$blocks.value.length > 0 ||
this.graph.rootStore.connectionsList.$connections.value.length > 0);
}
get isUnstable() {
const hasProcessingQueue = this.processQueue.isScheduled() || this.queue.size > 0;
const hasZeroUsableRect = this.$usableRect.value.height === 0 &&
this.$usableRect.value.width === 0 &&
this.$usableRect.value.x === 0 &&
this.$usableRect.value.y === 0;
// If graph has no elements, it's stable even with zero usableRect
if (hasZeroUsableRect && !this.hasGraphElements()) {
return hasProcessingQueue;
}
return hasProcessingQueue || hasZeroUsableRect;
}
/**
* Load array of HitBox items
* @param items Array of HitBox items to load
* @returns void
*/
load(items) {
// For usableRectTracker use incremental addition (optimal)
this.usableRectTracker.clear();
for (const item of items) {
if (item.affectsUsableRect) {
this.usableRectTracker.add(item);
}
}
this.interactiveTree.load(items);
}
/**
* Update HitBox item with new bounds
* @param item HitBox item to update
* @param bbox New bounds data
* @param _force Force update flag
* @returns void
*/
update(item, bbox, _force = false) {
this.queue.set(item, bbox);
this.processQueue();
}
/**
* Clear all HitBox items and reset state
*/
clear() {
this.processQueue.cancel();
this.queue.clear();
this.interactiveTree.clear();
this.usableRectTracker.clear();
this.updateUsableRect();
}
/**
* Add new HitBox item
* @param item HitBox item to add
*/
add(item) {
if (item.destroyed) {
return;
}
this.queue.set(item, item);
this.processQueue();
}
/**
* Wait for usableRect to become stable and then call callback
* @param callback Function to call when usableRect becomes stable
* @returns Unsubscribe function
*/
waitUsableRectUpdate(callback) {
// For empty graphs, immediately call callback with current usableRect
if (!this.hasGraphElements()) {
callback(this.$usableRect.value);
return noop;
}
if (this.isUnstable) {
const removeListener = this.$usableRect.subscribe(() => {
if (!this.isUnstable) {
removeListener();
callback(this.$usableRect.value);
return;
}
return;
});
return removeListener;
}
callback(this.$usableRect.value);
return noop;
}
updateUsableRect() {
// Use optimized tracker for elements affecting usableRect
const rect = this.usableRectTracker.toJSON();
const usableRect = {
x: Number.isFinite(rect.minX) ? rect.minX : 0,
y: Number.isFinite(rect.minY) ? rect.minY : 0,
width: Number.isFinite(rect.maxX) ? rect.maxX - rect.minX : 0,
height: Number.isFinite(rect.maxY) ? rect.maxY - rect.minY : 0,
};
if (usableRect.x === this.$usableRect.value.x &&
usableRect.y === this.$usableRect.value.y &&
usableRect.width === this.$usableRect.value.width &&
usableRect.height === this.$usableRect.value.height) {
return;
}
this.$usableRect.value = usableRect;
}
/**
* Remove HitBox item
* @param item HitBox item to remove
*/
remove(item) {
this.queue.set(item, null);
this.processQueue();
}
/**
* Test hit at specific point
* @param point Point to test
* @param pixelRatio Pixel ratio for coordinate conversion
* @returns Array of hit components
*/
testPoint(point, pixelRatio) {
return this.testHitBox({
minX: point.x - 1,
minY: point.y - 1,
maxX: point.x + 1,
maxY: point.y + 1,
x: point.origPoint?.x * pixelRatio,
y: point.origPoint?.y * pixelRatio,
});
}
/**
* Test hit box intersection with interactive elements
* @param item Hit box data to test
* @returns Array of hit components
*/
testBox(item) {
// Use interactive elements tree for hit testing
return this.interactiveTree.search(item).map((hitBox) => hitBox.item);
}
/**
* Subscribe to usableRect updates
* @param callback Function to call when usableRect changes
* @returns Unsubscribe function
*/
onUsableRectUpdate(callback) {
return this.$usableRect.subscribe(callback);
}
/**
* Get current usableRect value
* @returns Current usableRect
*/
getUsableRect() {
return this.$usableRect.value;
}
/*
* Destroy HitTest system, clears all items and stops processing queue
* @returns void
*/
destroy() {
this.clear();
super.destroy();
}
/**
* Test hit box intersection with interactive elements and sort by z-index
* @param item Hit box data to test
* @returns Array of hit components sorted by z-index
*/
/**
* Test hit box intersection with interactive elements and sort by z-index
* @param item Hit box data to test
* @returns Array of hit components sorted by z-index
*/
testHitBox(item) {
// Use interactive elements tree for hit testing
const hitBoxes = this.interactiveTree.search(item);
const result = [];
for (let i = 0; i < hitBoxes.length; i++) {
if (hitBoxes[i].item.onHitBox(item)) {
result.push(hitBoxes[i].item);
}
}
const res = result.sort((a, b) => {
const aZIndex = typeof a.zIndex === "number" ? a.zIndex : -1;
const bZIndex = typeof b.zIndex === "number" ? b.zIndex : -1;
if (aZIndex !== bZIndex) {
return bZIndex - aZIndex;
}
const aOrder = typeof a.renderOrder === "number" ? a.renderOrder : -1;
const bOrder = typeof b.renderOrder === "number" ? b.renderOrder : -1;
return bOrder - aOrder;
});
return res;
}
}
export class HitBox {
constructor(item, hitTest) {
this.item = item;
this.hitTest = hitTest;
this.destroyed = false;
/**
* AffectsUsableRect flag uses to determine if the element affects the usableRect
* If true, the element will be added to the usableRect tracker
* if false, the element wont affect the usableRect
*/
this.affectsUsableRect = true;
this.rect = [0, 0, 0, 0];
this.unstable = true;
/**
* Update HitBox bounds
* @param minX Minimum X coordinate
* @param minY Minimum Y coordinate
* @param maxX Maximum X coordinate
* @param maxY Maximum Y coordinate
* @param force Force update even if bounds haven't changed
*/
this.update = (minX, minY, maxX, maxY, force) => {
if (this.destroyed)
return;
if (minX === this.minX && minY === this.minY && maxX === this.maxX && maxY === this.maxY && !force)
return;
this.unstable = true;
this.rect = [minX, minY, maxX - minX, maxY - minY];
this.hitTest.update(this, { minX, minY, maxX, maxY, x: this.x, y: this.y }, force);
};
this.affectsUsableRect = true;
}
/**
* Update HitBox rectangle data
* @param rect New rectangle data
*/
updateRect(rect) {
this.minX = rect.minX;
this.minY = rect.minY;
this.maxX = rect.maxX;
this.maxY = rect.maxY;
this.x = rect.x;
this.y = rect.y;
this.rect = [this.minX, this.minY, this.maxX - this.minX, this.maxY - this.minY];
this.unstable = false;
}
/**
* Get HitBox rectangle as array [x, y, width, height]
* @returns Rectangle array [x, y, width, height]
*/
getRect() {
return this.rect;
}
/**
* Remove HitBox from hit testing
*/
remove() {
this.hitTest.remove(this);
}
/**
* Destroy HitBox and remove from hit testing
*/
destroy() {
this.destroyed = true;
this.hitTest.remove(this);
}
setAffectsUsableRect(affectsUsableRect) {
this.affectsUsableRect = affectsUsableRect;
if (this.unstable) {
return;
}
this.hitTest.update(this, {
minX: this.minX,
minY: this.minY,
maxX: this.maxX,
maxY: this.maxY,
x: this.x,
y: this.y,
});
}
}