Skip to content
Snippets Groups Projects
renderer.tsx 13.1 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 { 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.
         */
    
        /**
         * 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,
    
            onNodeCreation: PropTypes.func,
            onNodeDeletion: PropTypes.func,
            onLinkCreation: PropTypes.func,
            onLinkDeletion: PropTypes.func,
    
            onEngineStop: PropTypes.func,
    
            /**
             * Collection of all currently selected nodes. Can also be undefined or empty.
             */
    
            selectedNodes: PropTypes.arrayOf(PropTypes.instanceOf(Node)).isRequired,
            settings: PropTypes.object.isRequired,
    
        };
    
        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.screen2GraphCoords = this.screen2GraphCoords.bind(this);
            this.handleNodeCanvasObject = this.handleNodeCanvasObject.bind(this);
            this.handleLinkCanvasObject = this.handleLinkCanvasObject.bind(this);
    
            this.allowForceSimulation = this.allowForceSimulation.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);
            });
    
            this.state = {
                selectedNodes: [], // TODO: Why was undefined allowed here?
            };
    
    
        public allowForceSimulation() {
            this.warmupTicks = this.defaultWarmupTicks;
        }
    
    
        /**
         * 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.props.onNodeDeletion(
                    this.props.selectedNodes.map((node: Node) => node.id)
    
                // Connect to clicked node as parent while control is pressed
    
                if (this.props.selectedNodes.length == 0) {
    
                    // Have no node connected, so select
                    this.props.onNodeSelectionChanged([node]);
    
                } else if (!this.props.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]);
            }
        }
    
        /**
         * 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 }
        ) {
            // 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;
            }
    
    
                const node = this.props.onNodeCreation({
    
                // Select new node
                this.props.onNodeSelectionChanged([node]);
    
                // Just deselect
                this.props.onNodeSelectionChanged([]);
    
            }
        }
    
        private connectSelectionToNode(node: Node) {
    
            if (this.props.selectedNodes.length == 0) {
    
            if (this.props.selectedNodes.length == 1) {
    
                this.props.onLinkCreation(node.id, this.props.selectedNodes[0].id);
    
                this.props.selectedNodes.forEach((selectedNode: Node) =>
    
                    this.props.onLinkCreation(node.id, selectedNode.id)
    
                );
            }
        }
    
        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.props.onEngineStop();
    
            this.warmupTicks = 0; // Only warm up once, so stop warming up after the first freeze
        }
    
        private handleNodeDrag(node: Node) {
    
            // if (!this.props.selectedNodes.includes(node)) {
            //     this.props.onNodeSelectionChanged([...this.props.selectedNodes, node]);
            // }
    
            if (!this.props.connectOnDrag) {
    
            const closest = this.props.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
    
            this.props.onLinkCreation(node.id, closest.id);
    
        }
    
        private handleNodeCanvasObject(
            node: Node,
            ctx: CanvasRenderingContext2D,
            globalScale: number
        ) {
    
            const iconSize = 14;
            const isNodeHighlighted = this.props.selectedNodes.includes(node);
    
            this.drawNodeIcon(node, ctx, iconSize);
    
    
            // add ring just for highlighted nodes
    
            if (isNodeHighlighted) {
                this.drawNodeHighlight(ctx, node);
            }
    
            /**
             * 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
             */
            const drawLabel =
                this.props.selectedNodes.length == 0 ||
                isNodeHighlighted ||
                this.props.selectedNodes.some((n: Node) =>
                    n.neighbors.includes(node)
                );
    
            if (this.props.settings && drawLabel) {
                const labelHeightOffset = iconSize / 3;
                this.drawNodeLabel(node, globalScale, ctx, labelHeightOffset, 11);
    
        }
    
        private drawNodeLabel(
            node: Node,
            globalScale: number,
            ctx: CanvasRenderingContext2D,
            heightOffset = 6,
            fontSize = 11
        ) {
            const label = node.name;
            fontSize = fontSize / globalScale;
            ctx.font = `${fontSize}px Sans-Serif`;
            const textWidth = ctx.measureText(label).width;
            const width = textWidth + fontSize * 0.2;
            const height = fontSize * 1.2;
    
            ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
            ctx.fillRect(
                node.x - width / 2,
                node.y - height / 2 + height + heightOffset,
                width,
                height
            );
    
            ctx.textAlign = "center";
            ctx.textBaseline = "middle";
            ctx.fillStyle = "white";
            ctx.fillText(label, node.x, node.y + height + heightOffset);
        }
    
        private drawNodeHighlight(ctx: CanvasRenderingContext2D, node: 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();
        }
    
        private drawNodeIcon(
            node: Node,
            ctx: CanvasRenderingContext2D,
            iconSize: number
        ) {
    
            if (node.icon !== undefined) {
                const img = new Image();
                img.src = node.icon;
    
                ctx.drawImage(
                    img,
    
                    node.x - iconSize / 2,
                    node.y - iconSize / 2,
                    iconSize,
                    iconSize
    
                );
            }
        }
    
        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() {
            return (
    
                <div
                    tabIndex={0} // This is needed to receive focus events
                    onFocus={() => (this.graphInFocus = true)}
                    onBlur={() => (this.graphInFocus = false)}
                >
                    <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={(link: Link) =>
    
                            this.props.onLinkDeletion([link.id])
    
                            this.props.onNodeDeletion([node.id])
    
                        onBackgroundClick={(event: MouseEvent) =>
    
                            this.handleBackgroundClick(
                                event,
                                this.extractPositions(event)
                            )
                        }
                    />
                </div>