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);

Maximilian Giller
committed
this.handleElementRightClick = this.handleElementRightClick.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
return this.selectedNodes.some((node: Node) =>

Maximilian Giller
committed
node.links.find(element.equals)
);

Maximilian Giller
committed
} 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
this.selectNodes([node]);

Maximilian Giller
committed
/**
* Selects multiple nodes, or clears selection if given undefined or empty array.
* @param nodes Multiple nodes to mark as selected.
*/
private selectNodes(nodes: Node[]) {
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

Maximilian Giller
committed
let selectedNodes = this.state.selectedNodes.filter(

Maximilian Giller
committed
(n: Node) => n !== undefined
);

Maximilian Giller
committed
// Remove duplicates
selectedNodes = [...new Set(selectedNodes)];

Maximilian Giller
committed
if (selectedNodes.length > 0) {
return selectedNodes;
}
return undefined;
}
private handleNodeClick(node: Node) {
this.graphInFocus = true;

Maximilian Giller
committed
if (this.state.keys["Control"]) {
// Connect to clicked node as parent while control is pressed

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

Maximilian Giller
committed
this.connectSelectionToNode(node);

Maximilian Giller
committed
} else if (this.state.keys["Shift"]) {
this.toggleNodeSelection(node);
// By default, simply select node
this.selectNode(node);

Maximilian Giller
committed
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
private connectSelectionToNode(node: Node) {
if (this.selectedNodes === undefined) {
return;
}
if (this.selectedNodes.length == 1) {
node.connect(this.selectedNodes[0]);
return;
}
// More than one new link => custom save point handling
try {
this.state.graph.disableStoring();
this.selectedNodes.forEach((selectedNode: Node) =>
node.connect(selectedNode)
);
} finally {
this.state.graph.enableStoring();
this.state.graph.storeCurrentData(
"Added " +
this.selectedNodes.length +
" links on [" +
node.toString() +
"]"
);
}
}
private toggleNodeSelection(node: Node) {
// Convert selection to array as basis
let selection = this.selectedNodes;
if (selection === undefined) {
selection = [];
}
// 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.selectNodes(selection);
}
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.some((selectedNode: Node) =>

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

Maximilian Giller
committed
if (!this.selectedNodes || !this.selectedNodes.includes(node)) {
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();
}

Maximilian Giller
committed
/**
* Processes right-click event on graph elements by deleting them.
*/
private handleElementRightClick(element: GraphElement) {
this.graphInFocus = true;

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

Maximilian Giller
committed
this.selectNodes(selectedNodes.concat(this.selectedNodes));
render(): React.ReactNode {
return (
<div id="ks-editor">
<h1>Interface</h1>
<SpaceSelect onLoadSpace={this.loadSpace} />
<div id="force-graph-renderer" ref={this.graphContainer}>
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
<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}

Maximilian Giller
committed
onLinkRightClick={
this.handleElementRightClick
}
onNodeRightClick={
this.handleElementRightClick
}
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}
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
<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>

Maximilian Giller
committed
SHIFT+Click and drag on background to add nodes
to selection
<li>CTRL+Click background to clear selection</li>

Maximilian Giller
committed
<li>
SHIFT+Click node to add or remove from selection
</li>
<li>CTRL+Click another node to connect</li>
<li>Right-Click node to delete</li>
<li>Right-Click link to delete</li>
{this.state.connectOnDrag ? (
<li>
Drag node close to other node to connect
</li>
) : (
""
)}

Maximilian Giller
committed
<li>DELETE to delete selected nodes</li>
<li>ESCAPE to clear selection</li>
</ul>