Newer
Older
import * as Interactions from "../interactions";
import { Graph } from "../structures/graph/graph";
import { loadGraphJson } from "../../../datasets";
import { NodeDetails } from "./nodedetails";
import { SpaceSelect } from "./spaceselect";
import ReactForceGraph2d from "react-force-graph-2d";
import { Node } from "../structures/graph/node";
import { HistoryNavigator } from "./historynavigator";

Maximilian Giller
committed
import { GraphElement } from "../structures/graph/graphelement";
import { Link } from "../structures/graph/link";
import { NodeTypesEditor } from "./nodetypeseditor";
import { SpaceManager } from "./spacemanager";
type propTypes = any;
type stateTypes = {
graph: Graph;

Maximilian Giller
committed
visibleLabels: boolean;

Maximilian Giller
committed
connectOnDrag: boolean;

Maximilian Giller
committed
selectedNode: Node;
keys: { [name: string]: boolean };
type clickPosition = {
graph: { x: number; y: number };
window: { x: number; y: number };
};
type positionTranslate = {
x: number;
y: number;
z: number;
};
export class Editor extends React.PureComponent<propTypes, stateTypes> {
private maxDistanceToConnect = 15;

Maximilian Giller
committed
private defaultWarmupTicks = 100;
private warmupTicks = 100;

Maximilian Giller
committed
this.loadGraph = this.loadGraph.bind(this);
this.loadSpace = this.loadSpace.bind(this);
this.extractPositions = this.extractPositions.bind(this);
this.handleNodeClick = this.handleNodeClick.bind(this);

Maximilian Giller
committed
this.onHistoryChange = this.onHistoryChange.bind(this);

Maximilian Giller
committed
this.handleEngineStop = this.handleEngineStop.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this);
this.forceUpdate = this.forceUpdate.bind(this);

Maximilian Giller
committed
this.isHighlighted = this.isHighlighted.bind(this);
this.handleNodeCanvasObject = this.handleNodeCanvasObject.bind(this);
this.handleLinkCanvasObject = this.handleLinkCanvasObject.bind(this);
this.handleBackgroundClick = this.handleBackgroundClick.bind(this);
this.handleNodeDrag = this.handleNodeDrag.bind(this);
this.handleNodeDragEnd = this.handleNodeDragEnd.bind(this);
this.handleLinkClick = this.handleLinkClick.bind(this);
this.selectNode = this.selectNode.bind(this);
this.renderer = React.createRef();
// Set as new state
this.state = {
graph: undefined,

Maximilian Giller
committed
visibleLabels: true,

Maximilian Giller
committed
connectOnDrag: true,

Maximilian Giller
committed
selectedNode: undefined,
keys: {},
Interactions.initInteractions();
// Load initial space
this.loadSpace("space");
}
/**
* Loads a space from the database to the editor.
* @param spaceId Id of space to load.
* @returns Promise with boolean value that is true, if successful.
*/
public loadSpace(spaceId: string): any {
return loadGraphJson(spaceId).then(this.loadGraph);
}
/**
* Loads another graph based on the data supplied. Note: Naming currently suggests that this only loads a GRAPH, not a SPACE. Needs further work and implementation to see if that makes sense or not.
* @param data Serialized graph data.
* @returns True, if successful.
*/
public loadGraph(data: any): boolean {
console.log("Starting to load new graph ...");

Maximilian Giller
committed
this.warmupTicks = this.defaultWarmupTicks; // Should only run for each newly initialized graph, but then never again
// Is valid and parsed successfully?
if (newGraph === undefined) {
return false;
}
// .onNodeDragEnd((node: any, translate: any) =>
// Editor.globalState.onNodeDragEnd(node, translate)
// )
// .linkWidth((link: any) => Editor.globalState.linkWidth(link))
// .linkDirectionalParticles(
// Editor.globalState.linkDirectionalParticles()
// )
// .linkDirectionalParticleWidth((link: any) =>
// Editor.globalState.linkDirectionalParticleWidth(link)
// )
// .onBackgroundClick((event: any) =>
// Editor.globalState.onBackgroundClick(
// event,
// this.extractPositions(event)
// )
// )
// .onLinkClick((link: any) => Editor.globalState.onLinkClick(link));
console.log(newGraph);

Maximilian Giller
committed
this.state.graph.onChangeCallbacks.push(this.onHistoryChange);
// Subscribe to global key-press events
document.onkeydown = this.handleKeyDown;
document.onkeyup = this.handleKeyUp;
return true;
}
private handleKeyDown(event: KeyboardEvent) {
const key: string = event.key;

Maximilian Giller
committed
const keys = this.state.keys;
keys[key] = true;
this.setState({
keys: keys,
});
}
private handleKeyUp(event: KeyboardEvent) {
const key: string = event.key;

Maximilian Giller
committed
const keys = this.state.keys;
keys[key] = false;
this.setState({
keys: keys,
});
/**
* Handler for background click event on force graph. Adds new node by default.
* @param event Click event.
*/
private handleBackgroundClick(event: any, position: clickPosition) {
const newNode = new Node();
newNode.label = "Unnamed";
(newNode as any).fx = position.graph.x;
(newNode as any).fy = position.graph.y;
(newNode as any).x = position.graph.x;
(newNode as any).y = position.graph.y;
(newNode as any).vx = 0;
(newNode as any).vy = 0;
newNode.add(this.state.graph);
}

Maximilian Giller
committed
/**
* Propagates the changed state of the graph.
*/
private onHistoryChange() {
this.selectNode(undefined);

Maximilian Giller
committed
this.forceUpdate();
}

Maximilian Giller
committed
/**
* Should a given element be highlighted in rendering or not.
* @param element Element that should, or should not be highlighted.
* @returns True, if element should be highlighted.
*/
private isHighlighted(element: GraphElement): boolean {
if (this.state.selectedNode == undefined || element == undefined) {

Maximilian Giller
committed
// Default to false if nothing selected.
return false;
}
if (element.node) {
// Is node
return element.equals(this.state.selectedNode);
} else if (element.link) {
// Is link
// Is it one of the adjacent links?
const found = this.state.selectedNode.links.find(element.equals);

Maximilian Giller
committed
return found !== undefined;
} else {
return false;
}
}
/**
* 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): clickPosition {
graph: this.renderer.current.screen2GraphCoords(
event.layerX,
event.layerY
),
window: { x: event.clientX, y: event.clientY },
};
}
private selectNode(node: Node) {
this.setState({
selectedNode: node,
});
}
private handleNodeClick(node: Node) {
if (this.state.keys["Shift"]) {
// Connect two nodes when second select while shift is pressed
if (this.state.selectedNode == undefined) {
// Have no node connected, so select
this.selectNode(node);
} else if (!this.state.selectedNode.equals(node)) {
// Already have *other* node selected, so connect
this.state.selectedNode.connect(node);
}
} else if (this.state.keys["Control"]) {
// Delete node when control is pressed
node.delete();
} else {
// By default, simply select node
this.selectNode(node);

Maximilian Giller
committed
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
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
private handleNodeCanvasObject(node: any, ctx: any, globalScale: any) {
// add ring just for highlighted nodes
if (this.isHighlighted(node)) {
ctx.beginPath();
ctx.arc(node.x, node.y, 4 * 0.6, 0, 2 * Math.PI, false);
ctx.fillStyle = "red";
ctx.fill();
}
// Draw image
const imageSize = 12;
if (node.icon !== undefined) {
const img = new Image();
img.src = node.icon.link;
ctx.drawImage(
img,
node.x - imageSize / 2,
node.y - imageSize / 2,
imageSize,
imageSize
);
}
// Draw label
if (this.state.visibleLabels) {
const label = node.label;
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.x - bckgDimensions[0] / 2,
node.y - bckgDimensions[1] / 2 + nodeHeightOffset,
...bckgDimensions
);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "white";
ctx.fillText(label, node.x, node.y + nodeHeightOffset);
}
// TODO: Render label as always visible
}
private handleLinkCanvasObject(link: any, ctx: any, globalScale: any): any {

Maximilian Giller
committed
// Links already initialized?
if (link.source.x === undefined) {
return undefined;
}
// 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.isHighlighted(link)) {
lineWidth = 3;
}
lineWidth /= globalScale; // Scale with zoom

Maximilian Giller
committed
ctx.beginPath();
ctx.moveTo(link.source.x, link.source.y);
ctx.lineTo(link.target.x, link.target.y);
ctx.strokeStyle = gradient;

Maximilian Giller
committed
ctx.stroke();
// Only render strokes on last link
// var lastLink = graph.data[Graph.GRAPH_LINKS][graph.data[Graph.GRAPH_LINKS].length - 1];
// if (link === lastLink) {
// ctx.stroke();
// }
return undefined;
}
private handleNodeDrag(node: Node, translate: positionTranslate) {
this.selectNode(node);

Maximilian Giller
committed
// Should run connect logic?
if (!this.state.connectOnDrag) {
return;
}
const closest = this.state.graph.getClosestOtherNode(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();
}
private handleNodeDragEnd(node: Node, translate: positionTranslate) {
return;
}
if (this.state.keys["Control"]) {
// Delete link when control is pressed
link.delete();
this.forceUpdate();
}

Maximilian Giller
committed
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
this.state.graph.storeCurrentData("Initial state", false);
this.forceUpdate();
}
render(): React.ReactNode {
// The id "ks-editor" indicates, that the javascript associated with this should automatically be executed
return (
<div id="ks-editor">
<h1>Interface</h1>
<SpaceSelect onLoadSpace={this.loadSpace} />
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
<div id="force-graph-renderer">
{this.state.graph ? (
<ReactForceGraph2d
ref={this.renderer}
width={2000}
graphData={this.state.graph.data}
onNodeClick={this.handleNodeClick}
autoPauseRedraw={false}
cooldownTicks={0}
warmupTicks={this.warmupTicks}
onEngineStop={this.handleEngineStop}
nodeCanvasObject={this.handleNodeCanvasObject}
nodeCanvasObjectMode={() => "after"}
linkCanvasObject={this.handleLinkCanvasObject}
linkCanvasObjectMode={() => "replace"}
onNodeDrag={this.handleNodeDrag}
onNodeDragEnd={this.handleNodeDragEnd}
onLinkClick={this.handleLinkClick}
onBackgroundClick={(event) =>
this.handleBackgroundClick(
event,
this.extractPositions(event)
)
}
/>
) : undefined}
</div>
<HistoryNavigator
spaceId="space"
historyObject={this.state.graph}

Maximilian Giller
committed
onChange={this.onHistoryChange}
<ul className="instructions">
<li>Click background to create node</li>
<li>Click node to select and edit</li>
<li>CTRL+Click node to delete</li>
<li>SHIFT+Click a second node to connect</li>

Maximilian Giller
committed
{this.state.connectOnDrag ? (
<li>
Drag node close to other node to connect
</li>
) : (
""
)}

Maximilian Giller
committed
selectedNode={this.state.selectedNode}
allTypes={
this.state.graph ? this.state.graph.types : []
}
onChange={this.forceUpdate}
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
<h3>Node types</h3>
<NodeTypesEditor
onChange={this.forceUpdate}
graph={this.state.graph}
/>
<hr />
<h3>Settings</h3>
<input
id="node-labe-visibility"
type={"checkbox"}
checked={this.state.visibleLabels}
onChange={(event) => {
const newValue = event.target.checked;
if (newValue == this.state.visibleLabels) {
return;
}
this.setState({
visibleLabels: newValue,
});
}}
/>
<label htmlFor="node-labe-visibility">
Node labels
</label>
<br />
<input
id="connect-on-drag"
type={"checkbox"}
checked={this.state.connectOnDrag}
onChange={(event) => {
const newValue = event.target.checked;
if (newValue == this.state.connectOnDrag) {
return;
}
this.setState({
connectOnDrag: newValue,
});
}}
/>
<label htmlFor="connect-on-drag">
Connect nodes when dragged
</label>