UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

354 lines (353 loc) 12.3 kB
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, }); } }