@gravity-ui/graph
Version:
Modern graph editor component
185 lines (184 loc) • 6.03 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";
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);
}
}