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.keys = {}; 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; if (this.keys["Control"]) { // 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 if (this.keys["Control"]) { 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 if (this.keys["Shift"]) { // 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) { return; } 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) ) } /> ); } }