import React from "react"; import { DynamicGraph } from "./graph"; import { loadGraphJson } from "../common/datasets"; import { NodeDetails } from "./components/nodedetails"; import { SpaceSelect } from "./components/spaceselect"; import "./editor.css"; 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; }; type stateTypes = { /** * Graph structure holding the basic information. */ graph: DynamicGraph; /** * 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; /** * 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. */ keys: { [name: string]: boolean }; /** * Current width of graph object. Used to specifically adjust and correct the graph size. */ graphWidth: number; }; /** * Coordinate structure used for the force-graph. */ type graphCoordinates = { x: number; y: number; }; /** * Easy to access format for translated positions of a click event. */ type clickPosition = { graph: graphCoordinates; window: graphCoordinates; }; /** * Knowledge space graph editor. Allows easy editing of the graph structure. */ export class Editor extends React.PureComponent<propTypes, stateTypes> { private maxDistanceToConnect = 15; private defaultWarmupTicks = 100; private warmupTicks = 100; private renderer: React.RefObject<any>; private graphContainer: React.RefObject<HTMLDivElement>; /** * True, if the graph was the target of the most recent click event. */ private graphInFocus = false; constructor(props: propTypes) { super(props); // 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); this.handleEngineStop = this.handleEngineStop.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); this.handleKeyUp = this.handleKeyUp.bind(this); this.forceUpdate = this.forceUpdate.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); 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, visibleLabels: true, connectOnDrag: false, selectedNodes: [], // TODO: Why was undefined allowed here? keys: {}, graphWidth: 1000, }; } /** * 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 ..."); console.log(data); // Create graph const graph = new DynamicGraph(); graph.fromSerializedObject(data); this.warmupTicks = this.defaultWarmupTicks; // Should only run for each newly initialized graph, but then never again // Set as new state console.log(graph); this.setState({ graph: graph, }); graph.onChangeCallbacks.push(this.onGraphDataChange); // Subscribe to global events document.onkeydown = this.handleKeyDown; document.onkeyup = this.handleKeyUp; document.onmousedown = this.handleMouseDown; window.onresize = this.handleResize; this.handleResize(); return true; } /** * 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; const keys = this.state.keys; keys[key] = true; this.setState({ keys: keys, }); this.handleShortcutEvents(key); } /** * Triggers actions that correspond with certain shortcuts. * * @param key Newly pressed key. */ private handleShortcutEvents(key: string) { if (key === "Escape") { this.selectNode(undefined); } else if ( key === "Delete" && this.graphInFocus // Only delete if 2d-graph is the focused element ) { this.deleteSelectedNodes(); } } /** * Deletes all nodes currently selected. Handles store points accordingly of the number of deleted nodes. */ private deleteSelectedNodes() { const selectedNodes = this.state.selectedNodes; if (selectedNodes.length == 1) { selectedNodes[0].delete(); selectedNodes.pop(); this.selectNodes(selectedNodes); } else { selectedNodes.forEach((node: Node) => node.delete()); this.deselect(); } } /** * Processes page wide mouse down events. */ private handleMouseDown() { this.graphInFocus = false; } /** * Processes page wide key up events. Stores corresponding key as not-pressed in state. */ private handleKeyUp(event: KeyboardEvent) { const key: string = event.key; const keys = this.state.keys; keys[key] = false; this.setState({ keys: keys, }); } /** * 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; // 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 ); if (nearestNode !== undefined && nearestNode.distance < 4) { this.handleNodeClick(nearestNode.node); return; } // Just deselect if control key is pressed 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); } else { this.selectNode(node); } } /** * Propagates the changed state of the graph. */ private onGraphDataChange() { const nodes: Node[] = this.state.selectedNodes.map((node: Node) => this.state.graph.node(node.id) ); this.selectNodes(nodes); this.forceUpdate(); // TODO } /** * 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 { return { graph: this.renderer.current.screen2GraphCoords( event.layerX, // TODO: Replace layerx/layery non standard properties and fix typing event.layerY ), window: { x: event.clientX, y: event.clientY }, }; } private deselect() { this.setState({ selectedNodes: [] }); } /** * Selects a single node, or clears selection if given undefined. * @param node Single node to select, or undefined. */ private selectNode(node: Node) { this.selectNodes([node]); } /** * Selects multiple nodes, or clears selection if given undefined or empty array. * @param nodes Multiple nodes to mark as selected. */ private selectNodes(nodes: Node[]) { this.setState({ 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; // } private handleNodeClick(node: Node) { this.graphInFocus = true; 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)) { // Already have *other* node/s selected, so connect this.connectSelectionToNode(node); } } else if (this.state.keys["Shift"]) { this.toggleNodeSelection(node); } else { // By default, simply select node this.selectNode(node); } this.forceUpdate(); // TODO: Remove? } private connectSelectionToNode(node: Node) { if (this.state.selectedNodes.length == 0) { return; } if (this.state.selectedNodes.length == 1) { node.connect(this.state.selectedNodes[0]); } else { this.state.selectedNodes.forEach((selectedNode: Node) => node.connect(selectedNode) ); } } private toggleNodeSelection(node: Node) { // Convert selection to array as basis let selection = this.state.selectedNodes; // 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 // add ring just for highlighted nodes if (this.state.selectedNodes.includes(node)) { // Outer circle 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); ctx.fillStyle = node.type.color; ctx.fill(); } // Draw image const imageSize = 12; if (node.icon !== undefined) { const img = new Image(); img.src = node.icon; ctx.drawImage( img, node.x - imageSize / 2, node.y - imageSize / 2, imageSize, imageSize ); } // Draw label /** * 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 */ // 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 // ); // } // TODO: Render label as always visible } private handleLinkCanvasObject( link: Link, ctx: CanvasRenderingContext2D, globalScale: number ) { // Links already initialized? if (link.source.x === undefined) { return; } // 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.state.selectedNodes.some((node: Node) => node.links.find(link.equals) ) ) { lineWidth = 2; } lineWidth /= globalScale; // Scale with zoom ctx.beginPath(); ctx.moveTo(link.source.x, link.source.y); ctx.lineTo(link.target.x, link.target.y); ctx.strokeStyle = gradient; ctx.lineWidth = lineWidth; ctx.stroke(); } private handleNodeTypeSelect(type: NodeType) { const nodesWithType = this.state.graph.nodes.filter((n: Node) => n.type.equals(type) ); this.selectNodes(nodesWithType); } private handleNodeDrag(node: Node) { this.graphInFocus = true; if ( !this.state.selectedNodes || !this.state.selectedNodes.includes(node) ) { this.selectNode(node); } // 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(); } /** * Processes right-click event on graph elements by deleting them. */ private handleElementRightClick(element: GraphElement<unknown, unknown>) { this.graphInFocus = true; element.delete(); this.forceUpdate(); // TODO: Necessary? } 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(); } private handleBoxSelect(selectedNodes: Node[]) { 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} /> <SpaceManager /> <div id="content"> <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} onLinkRightClick={ this.handleElementRightClick } onNodeRightClick={ this.handleElementRightClick } onBackgroundClick={(event: any) => this.handleBackgroundClick( event, this.extractPositions(event) ) } /> ) : undefined} </SelectLayer> </div> <div id="sidepanel"> <HistoryNavigator spaceId="space" history={this.state.graph} onChange={this.onGraphDataChange} /> <hr /> <NodeDetails selectedNodes={this.state.selectedNodes} allTypes={ this.state.graph ? this.state.graph.objectGroups : [] } onChange={this.forceUpdate} /> <hr /> <h3>Node types</h3> <NodeTypesEditor onChange={this.forceUpdate} graph={this.state.graph} onSelectAll={this.handleNodeTypeSelect} /> <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> <li> SHIFT+Click and drag on background to add nodes to selection </li> <li>CTRL+Click background to clear selection</li> <li>Click node to select and edit</li> <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> ) : ( "" )} <li>DELETE to delete selected nodes</li> <li>ESCAPE to clear selection</li> </ul> </div> </div> </div> ); } }