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
/**
* Graph structure holding the basic information.
*/

Maximilian Giller
committed
/**
* Should labels on nodes be rendered, or none at all.
*/

Maximilian Giller
committed
visibleLabels: boolean;

Maximilian Giller
committed
/**
* Should feature be enabled, that nodes get connected with a link of dragged close enough to each other?
*/

Maximilian Giller
committed
connectOnDrag: boolean;

Maximilian Giller
committed
/**
* Collection of all currently selected nodes. Can also be undefined or empty.
*/
selectedNodes: Node[];
/**
* True for each key, that is currently considered pressed. If key has not been pressed yet, it will not exist as dict-key.
*/

Maximilian Giller
committed
keys: { [name: string]: boolean };

Maximilian Giller
committed
/**
* Current width of graph object. Used to specifically adjust and correct the graph size.
*/
graphWidth: number;

Maximilian Giller
committed
/**
* Coordinate structure used for the force-graph.
*/
type graphCoordinates = {
x: number;
y: number;
};

Maximilian Giller
committed
/**
* Easy to access format for translated positions of a click event.
*/
graph: graphCoordinates;
window: graphCoordinates;

Maximilian Giller
committed
/**
* Knowledge space graph editor. Allows easy editing of the graph structure.
*/
export class Editor extends React.PureComponent<propTypes, stateTypes> {
private maxDistanceToConnect = 15;

Maximilian Giller
committed
private defaultWarmupTicks = 100;
private warmupTicks = 100;
private graphContainer: any;

Maximilian Giller
committed
/**
* True, if the graph was the target of the most recent click event.
*/
private graphInFocus = false;

Maximilian Giller
committed

Maximilian Giller
committed
// Making sure, all functions retain the proper this-bind
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.handleLinkClick = this.handleLinkClick.bind(this);
this.selectNode = this.selectNode.bind(this);
this.handleResize = this.handleResize.bind(this);
this.handleBoxSelect = this.handleBoxSelect.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
selectedNodes: undefined,

Maximilian Giller
committed
keys: {},
graphWidth: 1000,

Maximilian Giller
committed
/**
* Tries to load initial graph after webpage finished loading.
*/
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;
}

Maximilian Giller
committed
/**
* Processes page wide key down events. Stores corresponding key as pressed in state.
*
* Also triggers actions corresponding to shortcuts.
*/
private handleKeyDown(event: KeyboardEvent) {
const key: string = event.key;

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

Maximilian Giller
committed
this.handleShortcutEvents(key);
}
/**
* Triggers actions that correspond with certain shortcuts.
*
* @param key Newly pressed key.
*/
private handleShortcutEvents(key: string) {
if (key === "Escape") {
} else if (
key === "Delete" &&

Maximilian Giller
committed
this.graphInFocus // Only delete if 2d-graph is the focused element

Maximilian Giller
committed
this.deleteSelectedNodes();

Maximilian Giller
committed
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
/**
* Deletes all nodes currently selected. Handles store points accordingly of the number of deleted nodes.
*/
private deleteSelectedNodes() {
if (this.selectedNodes === undefined) {
return; // Nothing to delete
}
if (this.selectedNodes.length == 1) {
this.selectedNodes[0].delete();
return;
}
// Delete multiple connected nodes
const count: number = this.selectedNodes.length;
try {
// Disable storing temporarily to create just one big change.
this.state.graph.disableStoring();
this.selectedNodes.forEach((node: Node) => node.delete());
} finally {
this.state.graph.enableStoring();
this.state.graph.storeCurrentData(
"Deleted " + count + " nodes and all connected links"
);
}
}
/**
* Processes page wide mouse down events.
*/
private handleMouseDown() {
this.graphInFocus = false;
}

Maximilian Giller
committed
/**
* Processes page wide key up events. Stores corresponding key as not-pressed in state.
*/
private handleKeyUp(event: KeyboardEvent) {
const key: string = event.key;

Maximilian Giller
committed
const keys = this.state.keys;
keys[key] = false;
this.setState({
keys: keys,
});

Maximilian Giller
committed
/**
* Processes resize window event. Focusses on resizing the graph accordingly.
*/
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;
}
if (this.state.keys["Control"]) {
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() {

Maximilian Giller
committed
if (this.selectedNodes === undefined) {
this.selectNode(undefined);

Maximilian Giller
committed
this.forceUpdate();
return;

Maximilian Giller
committed
const nodes: Node[] = this.selectedNodes.map((node: Node) =>
this.state.graph.getNode(node.id)
);
this.selectNodes(nodes);

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 {

Maximilian Giller
committed
if (this.selectedNodes == undefined || element == undefined) {

Maximilian Giller
committed
// Default to false if nothing selected.
return false;
}
if (element.node) {

Maximilian Giller
committed
// Is one of nodes
return this.selectedNodes.includes(element as Node);

Maximilian Giller
committed
} else if (element.link) {
// Is link
// Is it one of the adjacent links?

Maximilian Giller
committed
const found = this.selectedNodes.find((node: Node) =>
node.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 },
};
}

Maximilian Giller
committed
/**
* Selects a single node, or clears selection if given undefined.
* @param node Single node to select, or undefined.
*/
private selectNode(node: Node) {

Maximilian Giller
committed
if (node === undefined) {
this.setState({
selectedNodes: undefined,
});
return;
}

Maximilian Giller
committed
selectedNodes: [node],

Maximilian Giller
committed
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
/**
* Selects multiple nodes, or clears selection if given undefined or empty array.
* @param nodes Multiple nodes to mark as selected.
*/
private selectNodes(nodes: Node[]) {
if (nodes.length <= 0) {
this.setState(undefined);
return;
}
this.setState({
selectedNodes: nodes,
});
}
/**
* Makes sure to always offer a valid format of the selected nodes. Is either undefined or contains at least one valid node. An empty array is never returned.
*/
private get selectedNodes(): Node[] {
if (this.state.selectedNodes === undefined) {
return undefined;
}
// Remove undefines
const selectedNodes = this.state.selectedNodes.filter(
(n: Node) => n !== undefined
);
if (selectedNodes.length > 0) {
return selectedNodes;
}
return undefined;
}
private handleNodeClick(node: Node) {
this.graphInFocus = true;
if (this.state.keys["Shift"]) {

Maximilian Giller
committed
// Connect to clicked node as parent while shift is pressed
if (this.selectedNodes == undefined) {
// Have no node connected, so select
this.selectNode(node);

Maximilian Giller
committed
} else if (!this.selectedNodes.includes(node)) {
// Already have *other* node/s selected, so connect
this.selectedNodes.forEach((selectedNode: Node) =>
node.connect(selectedNode)
);
}
} 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

Maximilian Giller
committed
/**
* 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 isNodeRelatedToSelection: boolean =

Maximilian Giller
committed
this.selectedNodes === undefined ||

Maximilian Giller
committed
!!this.selectedNodes.find((selectedNode: Node) =>
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;
}

Maximilian Giller
committed
private handleNodeDrag(node: Node) {
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();
}
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();
}

Maximilian Giller
committed
if (selectedNodes !== undefined && selectedNodes.length <= 0) {
return;
}
this.selectNodes(selectedNodes);
render(): React.ReactNode {
return (
<div id="ks-editor">
<h1>Interface</h1>
<SpaceSelect onLoadSpace={this.loadSpace} />
<div id="force-graph-renderer" ref={this.graphContainer}>
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
<SelectLayer
allNodes={
this.state.graph ? this.state.graph.nodes : []
}
screen2GraphCoords={
this.renderer.current
? this.renderer.current.screen2GraphCoords
: undefined
}
isEnable={() => this.state.keys["Shift"]}
onBoxSelect={this.handleBoxSelect}
>
{this.state.graph ? (
<ForceGraph2D
ref={this.renderer}
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"}
nodeColor={(node: Node) => node.type.color}
onNodeDrag={this.handleNodeDrag}
onLinkClick={this.handleLinkClick}
onBackgroundClick={(event: any) =>
this.handleBackgroundClick(
event,
this.extractPositions(event)
)
}
/>
) : undefined}
</SelectLayer>
<HistoryNavigator
spaceId="space"
historyObject={this.state.graph}

Maximilian Giller
committed
onChange={this.onHistoryChange}

Maximilian Giller
committed
selectedNode={
this.selectedNodes
? this.selectedNodes[0]
: undefined
}
allTypes={
this.state.graph ? this.state.graph.types : []
}
onChange={this.forceUpdate}
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
<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 and drag on background to select and
edit multiple nodes
</li>
<li>CTRL+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>