Newer
Older
import { DynamicGraph } from "./graph";
listAllSpaces,
loadGraphJson,
saveGraphJson,
} from "../common/datasets";
import SpaceSelect from "./components/spaceselect";
import * as Helpers from "../common/helpers";

Matthias Konitzny
committed
import { Node, NodeProperties } from "../common/graph/node";
import SelectLayer from "./components/selectlayer";
import { Coordinate2D, GraphData, SimGraphData } from "../common/graph/graph";
import { NodeType, NodeTypeData } from "../common/graph/nodetype";
import { GraphRenderer2D } from "./renderer";
import * as Config from "../config";
import Sidepanel from "./components/sidepanel";

Matthias Konitzny
committed
import { Link } from "../common/graph/link";
import { Checkpoint } from "../common/history";

Matthias Konitzny
committed
export interface NodeDataChangeRequest extends NodeProperties {
id: number;
type: NodeType;
}

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;
}
type stateTypes = {
/**
* Graph structure holding the basic information.
*/
graph: DynamicGraph;
settings: EditorSettings;

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

Maximilian Giller
committed
*/
graphWidth: number;

Maximilian Giller
committed
/**
* 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
/**
* Collection of all currently selected nodes. Can also be undefined or empty.

Maximilian Giller
committed
*/
selectedNodes: Node[];
spaces: string[];
spaceId: string;

Maximilian Giller
committed
/**
* Knowledge space graph editor. Allows easy editing of the graph structure.
*/
export class Editor extends React.PureComponent<any, stateTypes> {
private rendererRef: React.RefObject<GraphRenderer2D>;

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.renameSpace = this.renameSpace.bind(this);
this.createSpace = this.createSpace.bind(this);
this.deleteSpace = this.deleteSpace.bind(this);
this.duplicateSpace = this.duplicateSpace.bind(this);
this.saveSpace = this.saveSpace.bind(this);
this.forceUpdate = this.forceUpdate.bind(this);
this.handleNodeTypeSelect = this.handleNodeTypeSelect.bind(this);
this.handleNodeTypeDataChange =
this.handleNodeTypeDataChange.bind(this);
this.handleNodeTypeDeletion = this.handleNodeTypeDeletion.bind(this);
this.handleNodeTypeCreation = this.handleNodeTypeCreation.bind(this);
this.handleBoxSelect = this.handleBoxSelect.bind(this);
this.selectNodes = this.selectNodes.bind(this);

Matthias Konitzny
committed
this.handleNodeDataChange = this.handleNodeDataChange.bind(this);

Matthias Konitzny
committed
this.handleNodeCreation = this.handleNodeCreation.bind(this);
this.handleNodeDeletion = this.handleNodeDeletion.bind(this);
this.handleLinkCreation = this.handleLinkCreation.bind(this);
this.handleLinkDeletion = this.handleLinkDeletion.bind(this);
this.handleCheckpointRequest = this.handleCheckpointRequest.bind(this);
this.handleUndo = this.handleUndo.bind(this);
this.handleRedo = this.handleRedo.bind(this);
this.createCheckpoint = this.createCheckpoint.bind(this);
document.addEventListener("keydown", (e) => {
this.keyPressed(e.key);
});
document.addEventListener("keyup", (e) => {
this.keyReleased(e.key);
});
window.addEventListener("resize", () => this.handleResize());
this.rendererRef = React.createRef();
// Set as new state
this.state = {
graph: undefined,
settings: {
visibleLabels: true,
connectOnDrag: false,
},
graphWidth: 1000,
selectedNodes: [],
keys: {},
spaces: [],
spaceId: Config.SPACE,
keyPressed(key: string) {
const keys = this.state.keys;
keys[key] = true;
this.setState({ keys: { ...keys } });
}
keyReleased(key: string) {
const keys = this.state.keys;
keys[key] = false;
this.setState({ keys: { ...keys } });
}

Maximilian Giller
committed
/**
* Tries to load initial graph after webpage finished loading.
*/
// Get list of all available spaces and pick initial space based on that
this.loadListOfSpaces().then(
(spaces: string[]) => {
// Try to load first element in list of available spaces
// If not available, just load default space
const initialSpaceId =
spaces.length > 0 ? spaces[0] : this.state.spaceId;
this.loadSpace(initialSpaceId);
},
() => this.loadSpace(this.state.spaceId)
);
}
/**
* Fetches the most current list of available spaces from the server and updates the state accordingly.
*/
private loadListOfSpaces(): Promise<string[]> {
return listAllSpaces().then((spaces) => {
this.setState({ spaces: spaces });
return spaces;
});
}
/**
* 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): Promise<boolean> {
return loadGraphJson(spaceId)
.then((data: GraphData) => {
// Loading space might have created a new space, if requested space was not available
// Just in case, reload list of spaces
this.loadListOfSpaces();
return data;
})
.then((data: GraphData) => this.loadGraph(data, spaceId));
public saveSpace(): Promise<void> {
return saveGraphJson(
this.state.spaceId,
this.state.graph.toJSONSerializableObject()
).then(
() => {
console.log("Successfully saved space!");
() => {
console.log("Something went wrong when saving the space! :(");
alert("Something went wrong, could not save space.");
}
* @param data Serialized graph data.
* @returns True, if successful.
*/
public loadGraph(data: GraphData, id: string): boolean {
console.log("Starting to load new graph ...");
// Allow a single phase of force simulation when loading the graph.
if (this.rendererRef.current != undefined) {
this.rendererRef.current.allowForceSimulation();
}
const graph = new DynamicGraph();
graph.fromSerializedObject(data);
// Set as new state
console.log(graph);
graph: graph,
this.handleResize();
return true;
}

Maximilian Giller
committed
/**
* Processes resize window event. Focusses on resizing the graph accordingly.
*/
private handleResize() {
const newGraphWidth = Helpers.getClientWidth("knowledge-space-editor");
this.setState({
graphWidth: newGraphWidth,
});
}
handleBoxSelect(selectedNodes: Node[]) {
if (selectedNodes !== undefined && selectedNodes.length <= 0) {

Maximilian Giller
committed
return;
}

Matthias Konitzny
committed
this.selectNodes([
...new Set(selectedNodes.concat(this.state.selectedNodes)),
]);

Maximilian Giller
committed
}
/**
* Selects multiple nodes, or clears selection if given undefined or empty array.
* @param nodes Multiple nodes to mark as selected.
*/
public selectNodes(nodes: Node[]) {
this.setState({
selectedNodes: nodes,
});

Maximilian Giller
committed
}
private handleNodeTypeSelect(type: NodeType) {
const nodesWithType = this.state.graph.nodes.filter((n: Node) =>
n.type.equals(type)
);
this.selectNodes(nodesWithType);
}
private handleNodeDataChange(
nodeData: NodeDataChangeRequest[],
createCheckpoint = true
) {
if (nodeData.length == 0) {
return;
}
// Create a shallow copy of the graph object to trigger an update over setState

Matthias Konitzny
committed
const graph = Object.assign(new DynamicGraph(), this.state.graph);
// Modify node
for (const request of nodeData) {
const node = graph.node(request.id);
Object.assign(node, request);
}
// Create checkpoint
if (createCheckpoint) {
graph.createCheckpoint(`Modified ${nodeData.length} node(s) data.`);
}

Matthias Konitzny
committed
// Push shallow copy to state
this.setState({ graph: graph });
}
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
private handleNodeTypeDataChange(
nodeTypeData: NodeTypeData[],
createCheckpoint = true
) {
if (nodeTypeData.length == 0) {
return;
}
// Create a shallow copy of the graph object to trigger an update over setState
const graph = Object.assign(new DynamicGraph(), this.state.graph);
// Modify node type
for (const request of nodeTypeData) {
const node = graph.nodeType(request.id);
Object.assign(node, request);
}
// Create checkpoint
if (createCheckpoint) {
graph.createCheckpoint(
`Modified ${nodeTypeData.length} node(s) data.`
);
}
// Push shallow copy to state
this.setState({ graph: graph });
}
private handleNodeCreation(
position?: Coordinate2D,
createCheckpoint = true
): Node {

Matthias Konitzny
committed
const graph = Object.assign(new DynamicGraph(), this.state.graph);
const node = graph.createNode(undefined, position.x, position.y, 0, 0);
if (createCheckpoint) {
graph.createCheckpoint("Created new node.");
}

Matthias Konitzny
committed
this.setState({
graph: graph,
});
return node;
}
private handleNodeDeletion(ids: number[], createCheckpoint = true) {
if (ids.length == 0) {
return;
}

Matthias Konitzny
committed
const graph = Object.assign(new DynamicGraph(), this.state.graph);
ids.forEach((id) => graph.deleteNode(id));
const selectedNodes = this.state.selectedNodes.filter(
(node) => !ids.includes(node.id)
);
if (createCheckpoint) {
graph.createCheckpoint(`Deleted ${ids.length} nodes.`);
}
this.setState({ graph: graph, selectedNodes: selectedNodes });

Matthias Konitzny
committed
}
private handleLinkCreation(
source: number,
target: number,
createCheckpoint = true
): Link {

Matthias Konitzny
committed
const graph = Object.assign(new DynamicGraph(), this.state.graph);
const link = graph.createLink(source, target);
if (createCheckpoint) {
graph.createCheckpoint(
`Created link between ${graph.node(source).name} and ${
graph.node(target).name
}.`
);
}

Matthias Konitzny
committed
this.setState({ graph: graph });
return link;
}
private handleLinkDeletion(ids: number[], createCheckpoint = true) {

Matthias Konitzny
committed
const graph = Object.assign(new DynamicGraph(), this.state.graph);
ids.forEach((id) => graph.deleteLink(id));
if (createCheckpoint) {
graph.createCheckpoint(`Deleted ${ids.length} link(s).`);
}
this.setState({ graph: graph });
}
private handleNodeTypeCreation(createCheckpoint = true): NodeType {
const graph = Object.assign(new DynamicGraph(), this.state.graph);
const nodeType = graph.createObjectGroup();
if (createCheckpoint) {
graph.createCheckpoint("Created new node type.");
}
this.setState({ graph: graph });
return nodeType;
}
private handleNodeTypeDeletion(ids: number[], createCheckpoint = true) {
const graph = Object.assign(new DynamicGraph(), this.state.graph);
ids.forEach((id) => graph.deleteNodeType(id));
if (createCheckpoint) {
graph.createCheckpoint(`Deleted ${ids.length} node type(s).`);
}
this.setState({ graph: graph });
}
private loadGraphFromCheckpoint(checkpoint: Checkpoint<SimGraphData>) {
const graph = new DynamicGraph();
graph.fromSerializedObject(checkpoint.data);
// Transfer checkpoints to new graph object
graph.history.copyCheckpointsFromHistory(this.state.graph.history);

Matthias Konitzny
committed
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
const selectedNodes = this.state.selectedNodes
.map((node) => graph.node(node.id))
.filter((node) => node != undefined);
this.setState({ graph: graph, selectedNodes: selectedNodes });
}
private handleCheckpointRequest(id: number) {
const history = this.state.graph.history;
const checkpoint = history.resetToCheckpoint(id);
this.loadGraphFromCheckpoint(checkpoint);
}
private handleUndo() {
const history = this.state.graph.history;
const checkpoint = history.undo();
this.loadGraphFromCheckpoint(checkpoint);
}
private handleRedo() {
const history = this.state.graph.history;
const checkpoint = history.redo();
this.loadGraphFromCheckpoint(checkpoint);
}
private clearHistory(description = "") {
const graph = Object.assign(new DynamicGraph(), this.state.graph);
graph.clearHistory(description);

Matthias Konitzny
committed
this.setState({ graph: graph });
}
/**
* Creates a new Checkpoint in the graph history
* @param description Checkpoint description.
*/
private createCheckpoint(description: string) {
const graph = Object.assign(new DynamicGraph(), this.state.graph);
graph.createCheckpoint(description);
this.setState({ graph: graph });
}
/**
* @param newId Explicit id of space that should be deleted.
*/
private deleteSpace(spaceId: string): Promise<void> {
return deleteGraphJson(spaceId).then(() => {
// Select first space in list if available, otherwise select defaul space (which will be created)
const remainingSpacesList: string[] = this.state.spaces.filter(
(space: string) => space != spaceId
);
const selectSpaceId: string =
remainingSpacesList.length > 0
? remainingSpacesList[0]
this.loadSpace(selectSpaceId);
});
}
/**
* @param newId New id for currently selected space.
* @returns Promise is true, if new, renamed graph could be loaded successfully.
private renameSpace(newId: string): Promise<boolean> {
return saveGraphJson(newId, this.state.graph.toJSONSerializableObject())
.then(() => deleteGraphJson(this.state.spaceId))
.then(() => this.loadSpace(newId));
}
/**
* @param newId Id for the newly created space with the data of the currently selected space copied over.
* @returns Promise is true, if newly created graph could be loaded successfully.
private duplicateSpace(newId: string): Promise<boolean> {
return saveGraphJson(
newId,
this.state.graph.toJSONSerializableObject()
).then(() => this.loadSpace(newId));
}
/**
* @param newSpaceId Id for newly created space with the default empty space data.
* @returns Promise is true, if newly created graph could be loaded successfully.
private createSpace(newSpaceId: string): Promise<boolean> {
return this.loadSpace(newSpaceId);
render(): React.ReactNode {
return (
<div id="ks-editor">
<h1>Interface</h1>
<SpaceSelect
onLoadSpace={this.loadSpace}
onDeleteSpace={this.deleteSpace}
onRenameSpace={this.renameSpace}
onDuplicateSpace={this.duplicateSpace}
onCreateSpace={this.createSpace}
spaces={this.state.spaces}
spaceId={this.state.spaceId}
/>
{this.state.graph && (
<div id="content">
<div id="force-graph-renderer">
<SelectLayer
screen2GraphCoords={
this.rendererRef.current
? this.rendererRef.current
.screen2GraphCoords
: undefined
}
isEnabled={this.state.keys["Shift"]}
onBoxSelect={this.handleBoxSelect}
>
<GraphRenderer2D
ref={this.rendererRef}
graph={this.state.graph}
onNodeSelectionChanged={this.selectNodes}

Matthias Konitzny
committed
onNodeCreation={this.handleNodeCreation}
onNodeDeletion={this.handleNodeDeletion}
onLinkCreation={this.handleLinkCreation}
onLinkDeletion={this.handleLinkDeletion}
onEngineStop={() =>
this.clearHistory(
`Loaded graph ${this.state.spaceId}.`
)
}
selectedNodes={this.state.selectedNodes}
</SelectLayer>
</div>
<Sidepanel
graph={this.state.graph}
onCheckpointRequest={this.handleCheckpointRequest}
onUndo={this.handleUndo}
onRedo={this.handleRedo}
onNodeTypeSelect={this.handleNodeTypeSelect}
onNodeTypeCreation={this.handleNodeTypeCreation}
onNodeTypeDelete={this.handleNodeTypeDeletion}
onNodeTypeDataChange={this.handleNodeTypeDataChange}
onSettingsChange={(settings) =>
this.setState({ settings: settings })
}
selectedNodes={this.state.selectedNodes}
onNodeDataChange={this.handleNodeDataChange}
createCheckpoint={this.createCheckpoint}