-
Matthias Konitzny authoredMatthias Konitzny authored
editor.tsx 16.49 KiB
import React from "react";
import { DynamicGraph } from "./graph";
import {
listAllSpaces,
loadGraphJson,
saveGraphJson,
} from "../common/datasets";
import SpaceSelect from "./components/spaceselect";
import "./editor.css";
import * as Helpers from "../common/helpers";
import { Node, NodeProperties } from "../common/graph/node";
import { SpaceManager } from "./components/spacemanager";
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";
import { Link } from "../common/graph/link";
import { Checkpoint } from "../common/history";
export interface NodeDataChangeRequest extends NodeProperties {
id: number;
type: NodeType;
}
export interface EditorSettings {
/**
* Should labels on nodes be rendered, or none at all.
*/
visibleLabels: boolean;
/**
* Should feature be enabled, that nodes get connected with a link of dragged close enough to each other?
*/
connectOnDrag: boolean;
}
type stateTypes = {
/**
* Graph structure holding the basic information.
*/
graph: DynamicGraph;
settings: EditorSettings;
/**
* Current width of graph object. Used to specifically adjust and correct the graph size.
*/
graphWidth: number;
/**
* True for each key, that is currently considered pressed. If key has not been pressed yet, it will not exist as dict-key.
*/
keys: { [name: string]: boolean };
/**
* Collection of all currently selected nodes. Can also be undefined or empty.
*/
selectedNodes: Node[];
spaces: string[];
spaceId: string;
};
/**
* Knowledge space graph editor. Allows easy editing of the graph structure.
*/
export class Editor extends React.PureComponent<any, stateTypes> {
private rendererRef: React.RefObject<GraphRenderer2D>;
constructor(props: any) {
super(props);
// Making sure, all functions retain the proper this-bind
this.loadGraph = this.loadGraph.bind(this);
this.loadSpace = this.loadSpace.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);
this.handleNodeDataChange = this.handleNodeDataChange.bind(this);
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);
});
this.rendererRef = React.createRef();
listAllSpaces().then((spaces) => this.setState({ spaces: spaces }));
// 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 } });
}
/**
* Tries to load initial graph after webpage finished loading.
*/
componentDidMount() {
if (this.state.spaceId !== undefined) {
// Load initial space
this.loadSpace(this.state.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((data: GraphData) =>
this.loadGraph(data, spaceId)
);
}
public saveSpace() {
return saveGraphJson(
this.state.spaceId,
this.state.graph.toJSONSerializableObject()
).then(
() => console.log("Successfully saved space!"),
() => console.log("Something went wrong when saving the space! :(")
);
}
/**
* 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, id: string): boolean {
console.log("Starting to load new graph ...");
console.log(data);
// Allow a single phase of force simulation when loading the graph.
if (this.rendererRef.current != undefined) {
this.rendererRef.current.allowForceSimulation();
}
// Create graph
const graph = new DynamicGraph();
graph.fromSerializedObject(data);
// Set as new state
console.log(graph);
this.setState({
spaceId: id,
graph: graph,
});
//graph.onChangeCallbacks.push(this.onGraphDataChange);
// Subscribe to global events
window.addEventListener("resize", () => this.handleResize());
this.handleResize();
return true;
}
/**
* 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) {
return;
}
this.selectNodes([
...new Set(selectedNodes.concat(this.state.selectedNodes)),
]);
}
/**
* 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,
});
}
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
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.`);
}
// Push shallow copy to state
this.setState({ graph: graph });
}
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 {
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.");
}
this.setState({
graph: graph,
});
return node;
}
private handleNodeDeletion(ids: number[], createCheckpoint = true) {
if (ids.length == 0) {
return;
}
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 });
}
private handleLinkCreation(
source: number,
target: number,
createCheckpoint = true
): Link {
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
}.`
);
}
this.setState({ graph: graph });
return link;
}
private handleLinkDeletion(ids: number[], createCheckpoint = true) {
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);
// Restore selected nodes
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);
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 });
}
render(): React.ReactNode {
return (
<div id="ks-editor">
<h1>Interface</h1>
<SpaceSelect
onLoadSpace={this.loadSpace}
spaces={this.state.spaces}
spaceId={this.state.spaceId}
/>
<SpaceManager />
{this.state.graph && (
<div id="content">
<div id="force-graph-renderer">
<SelectLayer
nodes={this.state.graph.nodes}
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}
width={this.state.graphWidth}
onNodeSelectionChanged={this.selectNodes}
onNodeCreation={this.handleNodeCreation}
onNodeDeletion={this.handleNodeDeletion}
onLinkCreation={this.handleLinkCreation}
onLinkDeletion={this.handleLinkDeletion}
onEngineStop={() =>
this.clearHistory(
`Loaded graph ${this.state.spaceId}.`
)
}
selectedNodes={this.state.selectedNodes}
settings={this.state.settings}
/>
</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}
settings={this.state.settings}
onNodeDataChange={this.handleNodeDataChange}
onSave={this.saveSpace}
createCheckpoint={this.createCheckpoint}
/>
</div>
)}
</div>
);
}
}