Newer
Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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 = {};
70
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
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;
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
// 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)) {
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
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
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
// 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)
)
}
/>
);
}
}