@gravity-ui/graph
Version:
Modern graph editor component
387 lines (386 loc) • 13.7 kB
JavaScript
import { batch, signal } from "@preact/signals-core";
import merge from "lodash/merge";
import { PublicGraphApi } from "./api/PublicGraphApi";
import { BelowLayer } from "./components/canvas/layers/belowLayer/BelowLayer";
import { CursorLayer } from "./components/canvas/layers/cursorLayer";
import { GraphLayer } from "./components/canvas/layers/graphLayer/GraphLayer";
import { SelectionLayer } from "./components/canvas/layers/selectionLayer/SelectionLayer";
import { initGraphColors, initGraphConstants } from "./graphConfig";
import { scheduler } from "./lib/Scheduler";
import { HitTest } from "./services/HitTest";
import { Layers } from "./services/LayersService";
import { CameraService } from "./services/camera/CameraService";
import { RootStore } from "./store";
import { getXY } from "./utils/functions";
import { clearTextCache } from "./utils/renderers/text";
import { Point, isTRect } from "./utils/types/shapes";
export var GraphState;
(function (GraphState) {
GraphState[GraphState["INIT"] = 0] = "INIT";
GraphState[GraphState["ATTACHED"] = 1] = "ATTACHED";
GraphState[GraphState["READY"] = 2] = "READY";
})(GraphState || (GraphState = {}));
export class Graph {
getGraphCanvas() {
return this.graphLayer.getCanvas();
}
get graphColors() {
return this.$graphColors.value;
}
get graphConstants() {
return this.$graphConstants.value;
}
get blocks() {
return this.rootStore.blocksList;
}
get connections() {
return this.rootStore.connectionsList;
}
get selectionService() {
return this.rootStore.selectionService;
}
constructor(config, rootEl, graphColors, graphConstants) {
this.scheduler = scheduler;
this.cameraService = new CameraService(this);
this.layers = new Layers();
this.api = new PublicGraphApi(this);
this.eventEmitter = new EventTarget();
this.rootStore = new RootStore(this);
this.hitTest = new HitTest(this);
this.$graphColors = signal(initGraphColors);
this.$graphConstants = signal(initGraphConstants);
this.state = GraphState.INIT;
this.startRequested = false;
this.onUpdateSize = (event) => {
this.cameraService.set(event);
};
this.belowLayer = this.addLayer(BelowLayer, {});
this.graphLayer = this.addLayer(GraphLayer, {});
this.selectionLayer = this.addLayer(SelectionLayer, {});
this.cursorLayer = this.addLayer(CursorLayer, {});
this.selectionLayer.hide();
this.graphLayer.hide();
this.belowLayer.hide();
if (rootEl) {
this.attach(rootEl);
}
if (graphColors) {
this.setColors(graphColors);
}
if (graphConstants) {
this.setConstants(graphConstants);
}
this.setupGraph(config);
}
getGraphLayer() {
return this.graphLayer;
}
setColors(colors) {
this.$graphColors.value = merge({}, initGraphColors, colors);
this.emit("colors-changed", { colors: this.graphColors });
}
setConstants(constants) {
this.$graphConstants.value = merge({}, initGraphConstants, constants);
this.emit("constants-changed", { constants: this.graphConstants });
}
/**
* Zoom to center of camera
* @param zoomConfig - zoom config
* @param zoomConfig.x if set - zoom to x coordinate, else - zoom to center
* @param zoomConfig.y if set - zoom to y coordinate, else - zoom to center
* @param zoomConfig.scale camera scale
*
* @returns {undefined}
* */
zoom(zoomConfig) {
const { width, height } = this.cameraService.getCameraState();
this.cameraService.zoom(zoomConfig.x || width / 2, zoomConfig.y || height / 2, zoomConfig.scale);
}
zoomTo(target, config) {
if (target === "center") {
this.api.zoomToViewPort(config);
return;
}
if (isTRect(target)) {
this.api.zoomToRect(target, config);
return;
}
this.api.zoomToBlocks(target, config);
}
getElementsOverPoint(point, filter) {
const items = this.hitTest.testPoint(point, this.layers.getDPR());
if (filter && items.length > 0) {
return items.filter((item) => filter.some((Component) => item instanceof Component));
}
return items;
}
getElementOverPoint(point, filter) {
return this.getElementsOverPoint(point, filter)?.[0];
}
/**
* Returns the current viewport rectangle in camera space, expanded by threshold.
* @returns {TRect} Viewport rect in camera-relative coordinates
*/
getViewportRect() {
const CAMERA_VIEWPORT_TRESHOLD = this.graphConstants.system.CAMERA_VIEWPORT_TRESHOLD;
const rel = this.cameraService.getRelativeViewportRect(); // full viewport, ignores insets
const x = -rel.x - rel.width * CAMERA_VIEWPORT_TRESHOLD;
const y = -rel.y - rel.height * CAMERA_VIEWPORT_TRESHOLD;
const width = -rel.x + rel.width * (1 + CAMERA_VIEWPORT_TRESHOLD) - x;
const height = -rel.y + rel.height * (1 + CAMERA_VIEWPORT_TRESHOLD) - y;
return { x, y, width, height };
}
getElementsInViewport(filter) {
const viewportRect = this.getViewportRect();
return this.getElementsOverRect(viewportRect, filter);
}
getElementsOverRect(rect, filter) {
const items = this.hitTest.testBox({
minX: rect.x,
minY: rect.y,
maxX: rect.x + rect.width,
maxY: rect.y + rect.height,
});
if (filter.length && items.length > 0) {
return items.filter((item) => filter.some((Component) => item instanceof Component));
}
return items;
}
getPointInCameraSpace(event) {
const xy = getXY(this.graphLayer.getCanvas(), event);
const applied = this.cameraService.applyToPoint(xy[0], xy[1]);
return new Point(applied[0], applied[1], { x: xy[0], y: xy[1] });
}
updateEntities({ blocks, connections, }) {
batch(() => {
if (blocks?.length) {
this.rootStore.blocksList.updateBlocks(blocks);
}
if (connections?.length) {
this.rootStore.connectionsList.updateConnections(connections);
}
});
}
setEntities({ blocks, connections, }) {
batch(() => {
this.rootStore.blocksList.setBlocks(blocks || []);
this.rootStore.connectionsList.setConnections(connections || []);
});
}
on(type, cb, options) {
this.eventEmitter.addEventListener(type, cb, options);
return () => this.off(type, cb);
}
off(type, cb) {
this.eventEmitter.removeEventListener(type, cb);
}
/*
* Emit Graph's events
*/
emit(eventName, detail) {
const event = new CustomEvent(eventName, {
detail,
bubbles: false,
cancelable: true,
});
this.eventEmitter.dispatchEvent(event);
return event;
}
/*
* Emit Graph's event and execute default action if it is not prevented
*/
executеDefaultEventAction(eventName, detail, defaultCb) {
const event = this.emit(eventName, detail);
if (!event.defaultPrevented) {
defaultCb();
}
}
addLayer(layerCtor, props) {
// TODO: These types are too complicated, try to simplify them
return this.layers.createLayer(layerCtor, {
...props,
camera: this.cameraService,
graph: this,
});
}
detachLayer(layer) {
this.layers.detachLayer(layer);
}
setupGraph(config = {}) {
this.config = config;
this.rootStore.configurationName = config.configurationName;
this.setEntities({
blocks: config.blocks,
connections: config.connections,
});
if (config.settings) {
this.updateSettings(config.settings);
}
if (config.layers) {
config.layers.forEach(([layer, params]) => {
this.addLayer(layer, params);
});
}
}
updateSettings(settings) {
this.rootStore.settings.setupSettings(settings);
}
updateSize() {
this.layers.updateSize();
}
attach(rootEl) {
if (this.state === GraphState.READY) {
return;
}
rootEl[Symbol.for("graph")] = this;
this.layers.attach(rootEl);
const { width: rootWidth, height: rootHeight } = this.layers.getRootSize();
this.cameraService.set({ width: rootWidth, height: rootHeight });
this.setGraphState(GraphState.ATTACHED);
if (this.startRequested) {
this.startRequested = false;
this.start();
}
}
start(rootEl = this.layers.$root) {
if (this.state !== GraphState.ATTACHED) {
this.startRequested = true;
return;
}
if (this.state >= GraphState.READY) {
throw new Error("Graph already started");
}
if (rootEl) {
this.attach(rootEl);
}
this.layers.on("update-size", this.onUpdateSize);
this.layers.start();
this.scheduler.start();
this.setGraphState(GraphState.READY);
this.runAfterGraphReady(() => {
this.selectionLayer.show();
this.graphLayer.show();
this.belowLayer.show();
this.cursorLayer.show();
});
}
/**
* Graph is ready when the hitboxes are stable.
* In order to initialize hitboxes we need to start scheduler and wait untils every component registered in hitTest service
* Immediatelly after registering startign a rendering process.
* @param cb - Callback to run after graph is ready
*/
runAfterGraphReady(cb) {
this.hitTest.waitUsableRectUpdate(cb);
}
stop(full = false) {
this.layers.detach(full);
clearTextCache();
this.scheduler.stop();
this.setGraphState(this.layers.$root ? GraphState.ATTACHED : GraphState.INIT);
}
setGraphState(state) {
if (this.state === state) {
return;
}
this.state = state;
this.emit("state-change", { state: this.state });
}
clear() {
this.layers.detach();
clearTextCache();
}
detach() {
this.stop(true);
}
unmount() {
this.detach();
this.layers.off("update-size", this.onUpdateSize);
this.setGraphState(GraphState.INIT);
this.hitTest.clear();
this.layers.unmount();
clearTextCache();
this.rootStore.reset();
this.scheduler.stop();
}
/**
* Locks the cursor to a specific type, disabling automatic cursor changes.
*
* When the cursor is locked, it will remain fixed to the specified type
* and will not change automatically based on component interactions until
* unlockCursor() is called. This is useful during drag operations, loading
* states, or other situations where you want to override the default
* interactive cursor behavior.
*
* @param cursor - The cursor type to lock to
*
* @example
* ```typescript
* // Lock to loading cursor during async operation
* graph.lockCursor("wait");
*
* // Lock to grabbing cursor during drag operation
* graph.lockCursor("grabbing");
*
* // Lock to copy cursor for duplication operations
* graph.lockCursor("copy");
* ```
*
* @see {@link CursorLayer.lockCursor} for more details
* @see {@link unlockCursor} to return to automatic behavior
*/
lockCursor(cursor) {
this.cursorLayer.lockCursor(cursor);
}
/**
* Unlocks the cursor and returns to automatic cursor management.
*
* The cursor will immediately update to reflect the current state
* based on the component under the mouse (if any). This provides
* smooth transitions when ending drag operations or async tasks.
*
* @example
* ```typescript
* // After completing a drag operation
* graph.unlockCursor(); // Will show appropriate cursor for current hover state
*
* // After finishing an async operation
* await someAsyncTask();
* graph.unlockCursor(); // Returns to interactive cursor behavior
* ```
*
* @see {@link CursorLayer.unlockCursor} for more details
* @see {@link lockCursor} to override automatic behavior
*/
unlockCursor() {
this.cursorLayer.unlockCursor();
}
/**
* Returns the CursorLayer instance for advanced cursor management.
*
* Use this method when you need direct access to cursor layer functionality
* beyond the basic setCursor/unsetCursor API, such as checking the current
* mode or getting the component under the cursor.
*
* @returns The CursorLayer instance
*
* @example
* ```typescript
* const cursorLayer = graph.getCursorLayer();
*
* // Check current mode
* if (cursorLayer.isManual()) {
* console.log("Manual cursor:", cursorLayer.getManualCursor());
* }
*
* // Get component under cursor for debugging
* const target = cursorLayer.getCurrentTarget();
* console.log("Hovering over:", target?.constructor.name);
* ```
*
* @see {@link CursorLayer} for available methods and properties
*/
getCursorLayer() {
return this.cursorLayer;
}
}