Skip to content
Snippets Groups Projects
renderer.tsx 14 KiB
Newer Older
  • Learn to ignore specific revisions
  • import React from "react";
    import PropTypes, { InferType } from "prop-types";
    import { DynamicGraph } from "./graph";
    import { Node } from "../common/graph/node";
    import { ForceGraph2D } from "react-force-graph";
    import { Link } from "../common/graph/link";
    import { GraphElement } from "../common/graph/graphelement";
    import { Coordinate2D } from "../common/graph/graph";
    
    export class GraphRenderer2D extends React.PureComponent<
        InferType<typeof GraphRenderer2D.propTypes>,
        InferType<typeof GraphRenderer2D.stateTypes>
    > {
        private maxDistanceToConnect = 15;
        private defaultWarmupTicks = 100;
        private warmupTicks = 100;
        private forceGraph: React.RefObject<any>; // using typeof ForceGraph3d produces an error here...
    
        /**
         * True, if the graph was the target of the most recent click event.
         */
        private graphInFocus = false; // TODO: Remove?
        /**
         * True for each key, that is currently considered pressed. If key has not been pressed yet, it will not exist as dict-key.
         */
        private keys: { [name: string]: boolean };
    
        static propTypes = {
            graph: PropTypes.instanceOf(DynamicGraph).isRequired,
            width: PropTypes.number.isRequired,
            onNodeClicked: PropTypes.func,
            onNodeSelectionChanged: PropTypes.func,
            /**
             * Collection of all currently selected nodes. Can also be undefined or empty.
             */
            selectedNodes: PropTypes.arrayOf(PropTypes.instanceOf(Node)),
        };
    
        static stateTypes = {};
    
        constructor(props: InferType<typeof GraphRenderer2D.propTypes>) {
            super(props);
    
    
            this.handleNodeClick = this.handleNodeClick.bind(this);
            this.handleEngineStop = this.handleEngineStop.bind(this);
            this.handleNodeDrag = this.handleNodeDrag.bind(this);
            this.handleElementRightClick = this.handleElementRightClick.bind(this);
    
            this.screen2GraphCoords = this.screen2GraphCoords.bind(this);
            this.handleNodeCanvasObject = this.handleNodeCanvasObject.bind(this);
            this.handleLinkCanvasObject = this.handleLinkCanvasObject.bind(this);
    
            document.addEventListener("keydown", (e) => {
                this.keys[e.key] = true;
                this.handleShortcutEvents(e.key);
            });
            document.addEventListener("keyup", (e) => {
                this.keys[e.key] = false;
                this.handleShortcutEvents(e.key);
            });
            document.addEventListener(
                "mousedown",
                (e) => (this.graphInFocus = false)
            );
    
            this.state = {
                selectedNodes: [], // TODO: Why was undefined allowed here?
            };
    
    
            this.forceGraph = React.createRef();
        }
    
        /**
         * Deletes all nodes currently selected. Handles store points accordingly of the number of deleted nodes.
         */
        private deleteSelectedNodes() {
            const selectedNodes = this.state.selectedNodes;
    
            if (selectedNodes.length == 1) {
                selectedNodes[0].delete();
                selectedNodes.pop();
                this.props.onNodeSelectionChanged(selectedNodes);
            } else {
                selectedNodes.forEach((node: Node) => node.delete());
                this.props.onNodeSelectionChanged([]);
            }
        }
    
        /**
         * Triggers actions that correspond with certain shortcuts.
         *
         * @param key Newly pressed key.
         */
        private handleShortcutEvents(key: string) {
            if (key === "Escape") {
                this.props.onNodeSelectionChanged([]);
            } else if (
                key === "Delete" &&
                this.graphInFocus // Only delete if 2d-graph is the focused element
            ) {
                this.deleteSelectedNodes();
            }
        }
    
        private handleNodeClick(node: Node) {
            this.graphInFocus = true;
    
    
                // Connect to clicked node as parent while control is pressed
                if (this.state.selectedNodes.length == 0) {
                    // Have no node connected, so select
                    this.props.onNodeSelectionChanged([node]);
                } else if (!this.state.selectedNodes.includes(node)) {
                    // Already have *other* node/s selected, so connect
                    this.connectSelectionToNode(node);
                }
    
            } else if (this.keys["Shift"]) {
    
                this.toggleNodeSelection(node);
            } else {
                // By default, simply select node
                this.props.onNodeSelectionChanged([node]);
            }
            //this.forceUpdate(); // TODO: Remove?
        }
    
        /**
         * Handler for background click event on force graph. Adds new node by default.
         * @param event Click event.
         */
        private handleBackgroundClick(
            event: MouseEvent,
            position: { graph: Coordinate2D; window: Coordinate2D }
        ) {
            this.graphInFocus = true;
    
            // Is there really no node there? Trying to prevent small error, where this event is triggered, even if there is a node.
    
            const nearestNode = this.props.graph.getClosestNode(
    
                position.graph.x,
                position.graph.y
            );
            if (nearestNode !== undefined && nearestNode.distance < 4) {
                this.handleNodeClick(nearestNode.node);
                return;
            }
    
            // Just deselect if control key is pressed
    
                this.props.onNodeSelectionChanged([]);
                return;
            }
    
            // Add new node
            const node = this.state.graph.createNode(
                undefined,
                position.graph.x,
                position.graph.y,
                0,
                0
            );
            this.forceUpdate(); // TODO: Remove?
    
            // Select newly created node
    
                // Simply add to current selection of shift is pressed
                this.toggleNodeSelection(node);
            } else {
                this.props.onNodeSelectionChanged([node]);
            }
        }
    
        /**
         * Processes right-click event on graph elements by deleting them.
         */
        private handleElementRightClick(element: GraphElement<unknown, unknown>) {
            this.graphInFocus = true;
    
            element.delete();
            this.forceUpdate(); // TODO: Necessary?
        }
    
        /**
         * Propagates the changed state of the graph.
         */
        private onGraphDataChange() {
    
            const nodes: Node[] = this.props.selectedNodes.map((node: Node) =>
                this.props.graph.node(node.id)
    
            );
            this.props.onNodeSelectionChanged(nodes);
            this.forceUpdate(); // TODO
        }
    
        private connectSelectionToNode(node: Node) {
    
            if (this.props.selectedNodes.length == 0) {
    
            if (this.props.selectedNodes.length == 1) {
    
                node.connect(this.state.selectedNodes[0]);
            } else {
    
                this.props.selectedNodes.forEach((selectedNode: Node) =>
    
                    node.connect(selectedNode)
                );
            }
        }
    
        private toggleNodeSelection(node: Node) {
            // Convert selection to array as basis
    
            let selection = this.props.selectedNodes;
    
    
            // Add/Remove node
            if (selection.includes(node)) {
                // Remove node from selection
                selection = selection.filter((n: Node) => !n.equals(node));
            } else {
                // Add node to selection
                selection.push(node);
            }
            this.props.onNodeSelectionChanged(selection);
        }
    
        private handleEngineStop() {
            // Only do something on first stop for each graph
            if (this.warmupTicks <= 0) {
                return;
            }
    
            this.warmupTicks = 0; // Only warm up once, so stop warming up after the first freeze
        }
    
        private handleNodeDrag(node: Node) {
            this.graphInFocus = true;
    
            if (
                !this.state.selectedNodes ||
                !this.state.selectedNodes.includes(node)
            ) {
                this.props.onNodeSelectionChanged([node]);
            }
    
            // Should run connect logic?
            if (!this.state.connectOnDrag) {
                return;
            }
    
            const closest = this.state.graph.getClosestNode(node.x, node.y, node);
    
            // Is close enough for new link?
            if (closest.distance > this.maxDistanceToConnect) {
                return;
            }
    
            // Does link already exist?
            if (node.neighbors.includes(closest.node)) {
                return;
            }
    
            // Add link
            node.connect(closest.node);
            // this.forceUpdate(); TODO: Remove?
        }
    
        private handleNodeCanvasObject(
            node: Node,
            ctx: CanvasRenderingContext2D,
            globalScale: number
        ) {
            // TODO: Refactor
    
            // add ring just for highlighted nodes
    
            if (this.props.selectedNodes.includes(node)) {
    
                // Outer circle
                ctx.beginPath();
                ctx.arc(node.x, node.y, 4 * 0.7, 0, 2 * Math.PI, false);
                ctx.fillStyle = "white";
                ctx.fill();
    
                // Inner circle
                ctx.beginPath();
                ctx.arc(node.x, node.y, 4 * 0.3, 0, 2 * Math.PI, false);
                ctx.fillStyle = node.type.color;
                ctx.fill();
            }
    
            // Draw image
            const imageSize = 12;
            if (node.icon !== undefined) {
                const img = new Image();
                img.src = node.icon;
    
                ctx.drawImage(
                    img,
                    node.x - imageSize / 2,
                    node.y - imageSize / 2,
                    imageSize,
                    imageSize
                );
            }
    
            // Draw label
            /**
             * Nothing selected? => Draw all labels
             * If this nodes is considered highlighted => Draw label
             * If this node is a neighbor of a selected node => Draw label
             */
            // TODO: Reenable node label rendering
            // const isNodeRelatedToSelection: boolean =
            //     this.state.selectedNodes.length != 0 ||
            //     this.isHighlighted(node) ||
            //     this.selectedNodes.some((selectedNode: Node) =>
            //         selectedNode.neighbors.includes(node)
            //     );
            //
            // if (this.state.visibleLabels && isNodeRelatedToSelection) {
            //     const label = node.name;
            //     const fontSize = 11 / globalScale;
            //     ctx.font = `${fontSize}px Sans-Serif`;
            //     const textWidth = ctx.measureText(label).width;
            //     const bckgDimensions = [textWidth, fontSize].map(
            //         (n) => n + fontSize * 0.2
            //     ); // some padding
            //
            //     const nodeHeightOffset = imageSize / 3 + bckgDimensions[1];
            //     ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
            //     ctx.fillRect(
            //         (node as any).x - bckgDimensions[0] / 2,
            //         (node as any).y - bckgDimensions[1] / 2 + nodeHeightOffset,
            //         ...bckgDimensions
            //     );
            //
            //     ctx.textAlign = "center";
            //     ctx.textBaseline = "middle";
            //     ctx.fillStyle = "white";
            //     ctx.fillText(
            //         label,
            //         (node as any).x,
            //         (node as any).y + nodeHeightOffset
            //     );
            // }
    
            // TODO: Render label as always visible
        }
    
        private handleLinkCanvasObject(
            link: Link,
            ctx: CanvasRenderingContext2D,
            globalScale: number
        ) {
            // Links already initialized?
            if (link.source.x === undefined) {
                return;
            }
    
            // Draw gradient link
            const gradient = ctx.createLinearGradient(
                link.source.x,
                link.source.y,
                link.target.x,
                link.target.y
            );
            // Have reversed colors
            // Color at source node referencing the target node and vice versa
            gradient.addColorStop(0, link.target.type.color);
            gradient.addColorStop(1, link.source.type.color);
    
            let lineWidth = 0.5;
            if (
                this.props.selectedNodes.some((node: Node) =>
                    node.links.find(link.equals)
                )
            ) {
                lineWidth = 2;
            }
            lineWidth /= globalScale; // Scale with zoom
    
            ctx.beginPath();
            ctx.moveTo(link.source.x, link.source.y);
            ctx.lineTo(link.target.x, link.target.y);
            ctx.strokeStyle = gradient;
            ctx.lineWidth = lineWidth;
            ctx.stroke();
        }
    
        public screen2GraphCoords(x: number, y: number): Coordinate2D {
            return this.forceGraph.current.screen2GraphCoords(x, y);
        }
    
        /**
         * Calculates the corresponding coordinates for a click event for easier further processing.
         * @param event The corresponding click event.
         * @returns Coordinates in graph and coordinates in browser window.
         */
        private extractPositions(event: any): {
            graph: Coordinate2D;
            window: Coordinate2D;
        } {
            return {
                graph: this.screen2GraphCoords(
                    event.layerX, // TODO: Replace layerx/layery non standard properties and fix typing
                    event.layerY
                ),
                window: { x: event.clientX, y: event.clientY },
            };
        }
    
        render() {
            this.warmupTicks = this.defaultWarmupTicks;
            return (
                <ForceGraph2D
                    ref={this.forceGraph}
                    width={this.props.width}
                    graphData={this.props.graph}
                    onNodeClick={this.handleNodeClick}
                    autoPauseRedraw={false}
                    cooldownTicks={0}
                    warmupTicks={this.warmupTicks}
                    onEngineStop={this.handleEngineStop}
                    nodeCanvasObject={this.handleNodeCanvasObject}
                    nodeCanvasObjectMode={() => "after"}
                    linkCanvasObject={this.handleLinkCanvasObject}
                    linkCanvasObjectMode={() => "replace"}
                    nodeColor={(node: Node) => node.type.color}
                    onNodeDrag={this.handleNodeDrag}
                    onLinkRightClick={this.handleElementRightClick}
                    onNodeRightClick={this.handleElementRightClick}
                    onBackgroundClick={(event: any) =>
                        this.handleBackgroundClick(
                            event,
                            this.extractPositions(event)
                        )
                    }
                />
            );
        }
    }