UNPKG

@gravity-ui/graph

Version:

Modern graph editor component

291 lines (290 loc) 10.4 kB
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(); } }