Newer
Older
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.
*/

Matthias Konitzny
committed
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,

Matthias Konitzny
committed
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)

Matthias Konitzny
committed
);
}
}
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"]) {

Matthias Konitzny
committed
// Request new node
const node = this.props.onNodeCreation({

Matthias Konitzny
committed
x: position.graph.x,
y: position.graph.y,
});
// Select new node
this.props.onNodeSelectionChanged([node]);
} else {

Matthias Konitzny
committed
// Just deselect
this.props.onNodeSelectionChanged([]);
}
}
private connectSelectionToNode(node: Node) {
if (this.props.selectedNodes.length == 0) {
return;
}
if (this.props.selectedNodes.length == 1) {

Matthias Konitzny
committed
this.props.onLinkCreation(node.id, this.props.selectedNodes[0].id);
} else {
this.props.selectedNodes.forEach((selectedNode: Node) =>

Matthias Konitzny
committed
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

Matthias Konitzny
committed
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
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
);
}
}
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 (

Matthias Konitzny
committed
<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])

Matthias Konitzny
committed
}
onNodeRightClick={(node: Node) =>
this.props.onNodeDeletion([node.id])

Matthias Konitzny
committed
}
onBackgroundClick={(event: MouseEvent) =>

Matthias Konitzny
committed
this.handleBackgroundClick(
event,
this.extractPositions(event)
)
}
/>
</div>