Newer
Older
import { DynamicGraph } 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 { GraphData } from "../common/graph/graph";
import { NodeType } from "../common/graph/nodetype";
type propTypes = {
spaceId: string;
};

Maximilian Giller
committed
/**
* Graph structure holding the basic information.
*/
graph: DynamicGraph;

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 renderer: React.RefObject<any>;
private graphContainer: React.RefObject<HTMLDivElement>;

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);
this.onGraphDataChange = this.onGraphDataChange.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.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,
selectedNodes: [], // TODO: Why was undefined allowed here?

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: GraphData): boolean {
console.log("Starting to load new graph ...");
const graph = new DynamicGraph();
graph.fromSerializedObject(data);

Maximilian Giller
committed
this.warmupTicks = this.defaultWarmupTicks; // Should only run for each newly initialized graph, but then never again
console.log(graph);
graph: graph,
graph.onChangeCallbacks.push(this.onGraphDataChange);

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() {
const selectedNodes = this.state.selectedNodes;

Maximilian Giller
committed
if (selectedNodes.length == 1) {
selectedNodes[0].delete();
selectedNodes.pop();
this.selectNodes(selectedNodes);
} else {
selectedNodes.forEach((node: Node) => node.delete());
this.deselect();

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: MouseEvent, 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 nearestNode = this.state.graph.getClosestNode(
position.graph.x,
position.graph.y
);

Maximilian Giller
committed
if (nearestNode !== undefined && nearestNode.distance < 4) {
this.handleNodeClick(nearestNode.node);
return;
}
if (this.state.keys["Control"]) {
this.selectNode(undefined);
return;
}
// Add new node
const node = this.state.graph.createNode(
undefined,
position.graph.x,
position.graph.y,
0,
0
);
this.forceUpdate(); // TODO: Remove?
// Select newly created node
if (this.state.keys["Shift"]) {
// Simply add to current selection of shift is pressed
this.toggleNodeSelection(node);
this.selectNode(node);

Maximilian Giller
committed
/**
* Propagates the changed state of the graph.
*/
private onGraphDataChange() {
const nodes: Node[] = this.state.selectedNodes.map((node: Node) =>
this.state.graph.node(node.id)

Maximilian Giller
committed
);
this.selectNodes(nodes);
this.forceUpdate(); // TODO

Maximilian Giller
committed
}
/**
* 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, // TODO: Replace layerx/layery non standard properties and fix typing
window: { x: event.clientX, y: event.clientY },
};
}
private deselect() {
this.setState({ selectedNodes: [] });
}

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,
// /**
// * 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[] {
// // TODO: Here are a lot of things that should not be possible by design
//
// // Remove undefines
// let selectedNodes = this.state.selectedNodes.filter(
// (n: Node) => n !== undefined
// );
//
// // Remove duplicates
// selectedNodes = [...new Set(selectedNodes)];
//
// if (selectedNodes.length > 0) {
// return selectedNodes;
// }
//
// return undefined;
// }

Maximilian Giller
committed
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
if (this.state.selectedNodes.length == 0) {
// Have no node connected, so select
this.selectNode(node);
} else if (!this.state.selectedNodes.includes(node)) {

Maximilian Giller
committed
// 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);
this.forceUpdate(); // TODO: Remove?

Maximilian Giller
committed
private connectSelectionToNode(node: Node) {
if (this.state.selectedNodes.length == 0) {

Maximilian Giller
committed
return;
}
if (this.state.selectedNodes.length == 1) {
node.connect(this.state.selectedNodes[0]);
} else {
this.state.selectedNodes.forEach((selectedNode: Node) =>

Maximilian Giller
committed
node.connect(selectedNode)
);
}
}
private toggleNodeSelection(node: Node) {
// Convert selection to array as basis
let selection = this.state.selectedNodes;

Maximilian Giller
committed
// 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: CanvasRenderingContext2D,
globalScale: number
) {
// TODO: Refactor

Maximilian Giller
committed
// add ring just for highlighted nodes
if (this.state.selectedNodes.includes(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);

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.x - imageSize / 2,
node.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
*/
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
// TODO: Reenable node label rendering
// const isNodeRelatedToSelection: boolean =
// this.state.selectedNodes.length != 0 ||
// this.isHighlighted(node) ||
// this.selectedNodes.some((selectedNode: Node) =>
// selectedNode.neighbors.includes(node)
// );
//
// if (this.state.visibleLabels && isNodeRelatedToSelection) {
// const label = node.name;
// 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,
// ...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: Link,
ctx: CanvasRenderingContext2D,
globalScale: number
) {

Maximilian Giller
committed
// Links already initialized?
if (link.source.x === undefined) {

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

Maximilian Giller
committed
if (
this.state.selectedNodes.some((node: Node) =>
node.links.find(link.equals)
)
) {
}
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();
}
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.state.selectedNodes ||
!this.state.selectedNodes.includes(node)
) {

Maximilian Giller
committed
this.selectNode(node);
}

Maximilian Giller
committed
// Should run connect logic?
if (!this.state.connectOnDrag) {
return;
}
const closest = this.state.graph.getClosestNode(node.x, node.y, 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<unknown, unknown>) {
this.graphInFocus = true;

Maximilian Giller
committed
element.delete();
this.forceUpdate(); // TODO: Necessary?

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.forceUpdate();
}

Maximilian Giller
committed
if (selectedNodes !== undefined && selectedNodes.length <= 0) {
return;
}
this.selectNodes(selectedNodes.concat(this.state.selectedNodes));
render(): React.ReactNode {
return (
<div id="ks-editor">
<h1>Interface</h1>
<SpaceSelect onLoadSpace={this.loadSpace} />
<div id="force-graph-renderer" ref={this.graphContainer}>
<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={this.state.graph}
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"
history={this.state.graph.history}
onChange={this.onGraphDataChange}
selectedNodes={this.state.selectedNodes}
this.state.graph
? this.state.graph.objectGroups
: []
onChange={this.forceUpdate}
<h3>Node types</h3>
<NodeTypesEditor
onChange={this.forceUpdate}
graph={this.state.graph}
onSelectAll={this.handleNodeTypeSelect}
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
712
713
714
715
716
717
718
719
720
721
/>
<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>