"src/editor/js/editor.ts" did not exist on "f2520967a2d9a70514145e9663ed849220734075"
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 { 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)).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.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 = {};
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
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.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 }
) {
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.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
node.connect(closest.node); // TODO: Change must propagate
// this.forceUpdate(); TODO: Remove?
}
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
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
);
}
}
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 (
<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)
)
}
/>
);
}
}