Skip to content
Snippets Groups Projects
renderer.tsx 13.07 KiB
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.
     */
    private graphInFocus = false;
    /**
     * 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,
        /**
         * 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?
        };

        this.keys = {};
        this.forceGraph = React.createRef();
    }

    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)
            );
        }
    }

    private handleNodeClick(node: Node) {
        if (this.keys["Control"]) {
            // 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;
        }

        if (this.keys["Control"]) {
            // Request new node
            const node = this.props.onNodeCreation({
                x: position.graph.x,
                y: position.graph.y,
            });
            // Select new node
            this.props.onNodeSelectionChanged([node]);
        } else {
            // Just deselect
            this.props.onNodeSelectionChanged([]);
        }
    }

    private connectSelectionToNode(node: Node) {
        if (this.props.selectedNodes.length == 0) {
            return;
        }

        if (this.props.selectedNodes.length == 1) {
            this.props.onLinkCreation(node.id, this.props.selectedNodes[0].id);
        } else {
            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.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]);
        // }

        // Should run connect logic?
        if (!this.props.connectOnDrag) {
            return;
        }

        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])
                    }
                    onNodeRightClick={(node: Node) =>
                        this.props.onNodeDeletion([node.id])
                    }
                    onBackgroundClick={(event: MouseEvent) =>
                        this.handleBackgroundClick(
                            event,
                            this.extractPositions(event)
                        )
                    }
                />
            </div>
        );
    }
}