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;
private graphInFocus = false;

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
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;
}
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;
document.onmousedown = this.handleMouseDown;
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,
});
if (key === "Escape") {
// Only delete if 2d-graph is the focused element
} else if (
key === "Delete" &&
this.state.selectedNode !== undefined &&
this.graphInFocus
) {
private handleMouseDown(event: any) {
this.graphInFocus = false;
}
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) {
this.graphInFocus = true;

Maximilian Giller
committed
// Is there really no node there? Trying to prevent small error, where this event is triggered, even if there is a node.
const placeholderNode: Node = {
id: undefined,
x: position.graph.x,
y: position.graph.y,
} as unknown as Node;
const nearestNode =
this.state.graph.getClosestOtherNode(placeholderNode);
if (nearestNode !== undefined && nearestNode.distance < 4) {
this.handleNodeClick(nearestNode.node);
return;
}
const newNode = new Node();
newNode.label = "Unnamed";
(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);
this.forceUpdate();

Maximilian Giller
committed
/**
* Propagates the changed state of the graph.
*/
private onHistoryChange() {
if (this.state.selectedNode === undefined) {
this.selectNode(undefined);
} else {
this.selectNode(
this.state.graph.getNode(this.state.selectedNode.id)
);
}

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) {
this.graphInFocus = true;
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)) {
const selected = this.state.selectedNode;
// Already have *other* node selected, so connect
this.state.selectedNode.connect(node);
// Re-select original node for easier workflow
this.selectNode(selected);
}
} 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
private handleNodeCanvasObject(node: any, ctx: any, globalScale: any) {
// add ring just for highlighted nodes
if (this.isHighlighted(node)) {

Maximilian Giller
committed
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;

Maximilian Giller
committed
ctx.fill();
}
// Draw image
const imageSize = 12;
if (node.icon !== undefined) {
const img = new Image();

Maximilian Giller
committed
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
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 /= 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();
return undefined;
}
private handleNodeDrag(node: Node, translate: positionTranslate) {
this.graphInFocus = true;
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;
}
this.graphInFocus = true;
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 {
return (
<div id="ks-editor">
<h1>Interface</h1>
<SpaceSelect onLoadSpace={this.loadSpace} />
<div id="force-graph-renderer">
{this.state.graph ? (
<ReactForceGraph2d
ref={this.renderer}
width={2000}
graphData={{
nodes: this.state.graph.data.nodes,
links: this.state.graph.links,
}}
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}
linkDirectionalParticles={(link) =>
this.isHighlighted(link as GraphElement)
? 1
: 0
}
linkDirectionalParticleColor={() =>
this.state.selectedNode
? this.state.selectedNode.type.color
: "black"
}
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>
) : (
""
)}
<li>DELETE to delete selected node</li>
<li>ESCAPE to unselect</li>

Maximilian Giller
committed
selectedNode={this.state.selectedNode}
allTypes={
this.state.graph ? this.state.graph.types : []
}
onChange={this.forceUpdate}
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
<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>