-
Matthias Konitzny authored
Also fixed a bug which caused descriptions of deselected nodes to be still shown.
Matthias Konitzny authoredAlso fixed a bug which caused descriptions of deselected nodes to be still shown.
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>
);
}
}