UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

185 lines (184 loc) 6.03 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"; export class HitTest extends Emitter { constructor() { super(...arguments); this.tree = new RBush(9); this.$usableRect = signal({ x: 0, y: 0, width: 0, height: 0 }); // Single queue replaces all complex state tracking this.queue = new Map(); // Single debounced job replaces complex scheduler logic this.processQueue = debounce(() => { const items = []; for (const [item, bbox] of this.queue) { this.tree.remove(item); if (bbox) { item.updateRect(bbox); items.push(item); } } this.tree.load(items); this.queue.clear(); this.updateUsableRect(); this.emit("update", this); }, { priority: ESchedulerPriority.LOWEST, frameInterval: 1, }); } get isUnstable() { return (this.processQueue.isScheduled() || this.queue.size > 0 || (this.$usableRect.value.height === 0 && this.$usableRect.value.width === 0 && this.$usableRect.value.x === 0 && this.$usableRect.value.y === 0)); } load(items) { this.tree.load(items); } update(item, bbox, _force = false) { this.queue.set(item, bbox); this.processQueue(); // TODO: force update may be cause to unset unstable flag before graph is really made stable // Usually case: updateEntities update blocks and connections. In this case used force stategy, so every entity will be updated immediatelly, but async, so zoom will be unstable /* if (force) { this.processQueue.flush(); } */ } clear() { this.processQueue.cancel(); this.queue.clear(); this.tree.clear(); this.updateUsableRect(); } add(item) { if (item.destroyed) { return; } this.queue.set(item, item); this.processQueue(); } waitUsableRectUpdate(callback) { if (this.isUnstable) { const removeListener = this.$usableRect.subscribe(() => { if (!this.isUnstable) { removeListener(); callback(this.$usableRect.value); } }); return removeListener; } callback(this.$usableRect.value); return noop; } updateUsableRect() { const rect = this.tree.toJSON(); if (rect.length === 0) { this.$usableRect.value = { x: 0, y: 0, width: 0, height: 0 }; return; } this.$usableRect.value = { 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, }; } remove(item) { this.queue.set(item, null); this.processQueue(); } 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, }); } testBox(item) { return this.tree.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() { this.clear(); super.destroy(); } testHitBox(item) { const hitBoxes = this.tree.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; } return 0; }); return res; } } export class HitBox { constructor(item, hitTest) { this.item = item; this.hitTest = hitTest; this.destroyed = false; this.rect = [0, 0, 0, 0]; this.unstable = true; 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); }; } 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; } getRect() { return this.rect; } remove() { this.hitTest.remove(this); } destroy() { this.destroyed = true; this.hitTest.remove(this); } }