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