@gravity-ui/graph
Version:
Modern graph editor component
291 lines (290 loc) • 10.4 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 { 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;
}
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.$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.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];
}
getViewportRect() {
const CAMERA_VIEWPORT_TRESHOLD = this.graphConstants.system.CAMERA_VIEWPORT_TRESHOLD;
const cameraSize = this.cameraService.getCameraState();
const x = -cameraSize.relativeX - cameraSize.relativeWidth * CAMERA_VIEWPORT_TRESHOLD;
const y = -cameraSize.relativeY - cameraSize.relativeHeight * CAMERA_VIEWPORT_TRESHOLD;
const width = -cameraSize.relativeX + cameraSize.relativeWidth * (1 + CAMERA_VIEWPORT_TRESHOLD) - x;
const height = -cameraSize.relativeY + cameraSize.relativeHeight * (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();
});
}
/**
* 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();
}
}