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