Newer
Older
import { Graph } from "./graph";
import { loadGraphJson } from "../common/datasets";
import { NodeDetails } from "./components/nodedetails";
import { SpaceSelect } from "./components/spaceselect";
import { ForceGraph2D } from "react-force-graph";
import { Node } from "../common/graph/node";
import { HistoryNavigator } from "./components/historynavigator";
import { GraphElement } from "../common/graph/graphelement";
import { Link } from "../common/graph/link";
import { NodeTypesEditor } from "./components/nodetypeseditor";
import { SpaceManager } from "./components/spacemanager";
import { SelectLayer } from "./components/selectlayer";
import { NodeType } from "../structures/graph/nodetype";
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.
*/

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.handleNodeTypeSelect = this.handleNodeTypeSelect.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
/**
* 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"
);

Maximilian Giller
committed
/**
* 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();
// Select newly created node
if (this.state.keys["Shift"]) {
// Simply add to current selection of shift is pressed
this.toggleNodeSelection(newNode);
} else {
this.selectNode(newNode);
}

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[]) {

Maximilian Giller
committed
selectedNodes: nodes,

Maximilian Giller
committed
/**
* 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
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
471
472
473
474
475
476
477
478
479
480
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;
}
private handleNodeTypeSelect(type: NodeType) {
const nodesWithType = this.state.graph.nodes.filter((n: Node) =>
n.type.equals(type)
);
this.selectNodes(nodesWithType);
}

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}>
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
<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}
selectedNodes={this.selectedNodes}
allTypes={
this.state.graph ? this.state.graph.types : []
}
onChange={this.forceUpdate}
<h3>Node types</h3>
<NodeTypesEditor
onChange={this.forceUpdate}
graph={this.state.graph}
onSelectAll={this.handleNodeTypeSelect}
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
774
775
776
777
778
779
780
781
782
783
784
785
786
787
/>
<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>