Newer
Older
import { Graph } from "../structures/graph/graph";
import { loadGraphJson } from "../../../common/datasets";
import { NodeDetails } from "./nodedetails";
import { SpaceSelect } from "./spaceselect";
import { ForceGraph2D } from "react-force-graph";
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 = {
spaceId: string;
};

Maximilian Giller
committed
visibleLabels: boolean;

Maximilian Giller
committed
connectOnDrag: boolean;

Maximilian Giller
committed
selectedNode: Node;
keys: { [name: string]: boolean };
graphWidth: number;
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 graphContainer: any;
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.handleResize = this.handleResize.bind(this);
this.renderer = React.createRef();
this.graphContainer = React.createRef();
// Set as new state
this.state = {
graph: undefined,

Maximilian Giller
committed
visibleLabels: true,

Maximilian Giller
committed
selectedNode: undefined,
keys: {},
graphWidth: 1000,
componentDidMount() {
if (this.props.spaceId !== undefined) {
// Load initial space
this.loadSpace(this.props.spaceId);
}
}
/**
* 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);
newGraph.onChangeCallbacks.push(this.onHistoryChange);

Maximilian Giller
committed
// Subscribe to global events
document.onkeydown = this.handleKeyDown;
document.onkeyup = this.handleKeyUp;
document.onmousedown = this.handleMouseDown;
window.onresize = this.handleResize;
this.handleResize();
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,
});
private handleResize() {
const newGraphWidth = this.graphContainer.current.clientWidth;
this.setState({
graphWidth: newGraphWidth,
});
}
/**
* 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;
}
// Just deselect if shift key is pressed
if (this.state.keys["Shift"]) {
this.selectNode(undefined);
return;
}
// Add new node
(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);
private handleNodeCanvasObject(node: Node, ctx: any, globalScale: any) {

Maximilian Giller
committed
// add ring just for highlighted nodes
if (this.isHighlighted(node)) {

Maximilian Giller
committed
ctx.beginPath();
ctx.arc(
(node as any).x,
(node as any).y,
4 * 0.7,
0,
2 * Math.PI,
false
);
ctx.fillStyle = "white";
ctx.fill();
// Inner circle
ctx.beginPath();
ctx.arc(
(node as any).x,
(node as any).y,
4 * 0.3,
0,
2 * Math.PI,
false
);

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

Maximilian Giller
committed
ctx.drawImage(
img,
(node as any).x - imageSize / 2,
(node as any).y - imageSize / 2,

Maximilian Giller
committed
imageSize,
imageSize
);
}
// Draw label
const isNodeRelatedToSelection: boolean =
this.state.selectedNode === undefined ||
this.isHighlighted(node) ||
this.state.selectedNode.neighbors.includes(node);
if (this.state.visibleLabels && isNodeRelatedToSelection) {

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

Maximilian Giller
committed
...bckgDimensions
);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "white";
ctx.fillText(
label,
(node as any).x,
(node as any).y + nodeHeightOffset
);

Maximilian Giller
committed
}
// 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" ref={this.graphContainer}>
width={this.state.graphWidth}
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}
this.handleBackgroundClick(
event,
this.extractPositions(event)
)
}
/>
) : undefined}
</div>
<HistoryNavigator
spaceId="space"
historyObject={this.state.graph}

Maximilian Giller
committed
onChange={this.onHistoryChange}

Maximilian Giller
committed
selectedNode={this.state.selectedNode}
allTypes={
this.state.graph ? this.state.graph.types : []
}
onChange={this.forceUpdate}
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
<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>
<hr />
<ul className="instructions">
<li>Click background to create node</li>
<li>SHIFT+Click background to clear selection</li>
<li>Click node to select and edit</li>
<li>CTRL+Click node to delete</li>
<li>CTRL+Click link to delete</li>
<li>SHIFT+Click a second node to connect</li>
{this.state.connectOnDrag ? (
<li>
Drag node close to other node to connect
</li>
) : (
""
)}
<li>DELETE to delete selected node</li>
<li>ESCAPE to clear selection</li>
</ul>