import React from "react"; import { Graph } from "../structures/graph/graph"; import { loadGraphJson } from "../../../common/datasets"; import { NodeDetails } from "./nodedetails"; import { SpaceSelect } from "./spaceselect"; import "./editor.css"; import { ForceGraph2D } from "react-force-graph"; import { Node } from "../structures/graph/node"; import { HistoryNavigator } from "./historynavigator"; import { GraphElement } from "../structures/graph/graphelement"; import { Link } from "../structures/graph/link"; import { NodeTypesEditor } from "./nodetypeseditor"; import { SpaceManager } from "./spacemanager"; import { SelectLayer } from "./selectlayer"; type propTypes = { spaceId: string; }; type stateTypes = { /** * Graph structure holding the basic information. */ graph: Graph; /** * 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: any; private graphContainer: any; /** * 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.onHistoryChange = this.onHistoryChange.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.isHighlighted = this.isHighlighted.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.renderer = React.createRef(); this.graphContainer = React.createRef(); // Set as new state this.state = { graph: undefined, visibleLabels: true, connectOnDrag: false, selectedNodes: undefined, 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: any): boolean { console.log("Starting to load new graph ..."); console.log(data); // Create graph const newGraph = Graph.parse(data); this.warmupTicks = this.defaultWarmupTicks; // Should only run for each newly initialized graph, but then never again // Is valid and parsed successfully? if (newGraph === undefined) { return false; } // Set as new state console.log(newGraph); this.setState({ graph: newGraph, }); newGraph.onChangeCallbacks.push(this.onHistoryChange); // 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() { if (this.selectedNodes === undefined) { return; // Nothing to delete } if (this.selectedNodes.length == 1) { this.selectedNodes[0].delete(); return; } // Delete multiple connected nodes const count: number = this.selectedNodes.length; try { // Disable storing temporarily to create just one big change. this.state.graph.disableStoring(); this.selectedNodes.forEach((node: Node) => node.delete()); } finally { this.state.graph.enableStoring(); this.state.graph.storeCurrentData( "Deleted " + count + " nodes and all connected links" ); } } /** * 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: any, 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 placeholderNode: Node = { id: undefined, x: position.graph.x, y: position.graph.y, } as unknown as Node; const nearestNode = this.state.graph.getClosestOtherNode(placeholderNode); 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 newNode = new Node(); newNode.name = "Unnamed"; (newNode as any).x = position.graph.x; (newNode as any).y = position.graph.y; (newNode as any).vx = 0; (newNode as any).vy = 0; newNode.add(this.state.graph); this.forceUpdate(); } /** * Propagates the changed state of the graph. */ private onHistoryChange() { if (this.selectedNodes === undefined) { this.selectNode(undefined); this.forceUpdate(); return; } const nodes: Node[] = this.selectedNodes.map((node: Node) => this.state.graph.getNode(node.id) ); this.selectNodes(nodes); this.forceUpdate(); } /** * Should a given element be highlighted in rendering or not. * @param element Element that should, or should not be highlighted. * @returns True, if element should be highlighted. */ private isHighlighted(element: GraphElement): boolean { if (this.selectedNodes == undefined || element == undefined) { // Default to false if nothing selected. return false; } if (element.node) { // Is one of nodes return this.selectedNodes.includes(element as Node); } else if (element.link) { // Is link // Is it one of the adjacent links? return this.selectedNodes.some((node: Node) => node.links.find(element.equals) ); } else { return false; } } /** * 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, event.layerY ), window: { x: event.clientX, y: event.clientY }, }; } /** * 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[] { if (this.state.selectedNodes === undefined) { return undefined; } // 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.selectedNodes == undefined) { // Have no node connected, so select this.selectNode(node); } else if (!this.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(); } private connectSelectionToNode(node: Node) { if (this.selectedNodes === undefined) { return; } if (this.selectedNodes.length == 1) { node.connect(this.selectedNodes[0]); return; } // More than one new link => custom save point handling try { this.state.graph.disableStoring(); this.selectedNodes.forEach((selectedNode: Node) => node.connect(selectedNode) ); } finally { this.state.graph.enableStoring(); this.state.graph.storeCurrentData( "Added " + this.selectedNodes.length + " links on [" + node.toString() + "]" ); } } private toggleNodeSelection(node: Node) { // Convert selection to array as basis let selection = this.selectedNodes; if (selection === undefined) { selection = []; } // 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: any, globalScale: any) { // add ring just for highlighted nodes if (this.isHighlighted(node)) { // Outer circle ctx.beginPath(); ctx.arc( (node as any).x, (node as any).y, 4 * 0.7, 0, 2 * Math.PI, false ); ctx.fillStyle = "white"; ctx.fill(); // Inner circle ctx.beginPath(); ctx.arc( (node as any).x, (node as any).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 as any).x - imageSize / 2, (node as any).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 */ const isNodeRelatedToSelection: boolean = this.selectedNodes === undefined || 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: any, ctx: any, globalScale: any): any { // Links already initialized? if (link.source.x === undefined) { return undefined; } // 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.isHighlighted(link)) { 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(); return undefined; } private handleNodeDrag(node: Node) { this.graphInFocus = true; if (!this.selectedNodes || !this.selectedNodes.includes(node)) { this.selectNode(node); } // Should run connect logic? if (!this.state.connectOnDrag) { return; } const closest = this.state.graph.getClosestOtherNode(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) { this.graphInFocus = true; element.delete(); this.forceUpdate(); } 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.state.graph.storeCurrentData("Initial state", false); this.forceUpdate(); } private handleBoxSelect(selectedNodes: Node[]) { if (selectedNodes !== undefined && selectedNodes.length <= 0) { return; } this.selectNodes(selectedNodes.concat(this.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={{ nodes: this.state.graph.data.nodes, links: this.state.graph.links, }} 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" historyObject={this.state.graph} onChange={this.onHistoryChange} /> <hr /> <NodeDetails selectedNode={ this.selectedNodes ? this.selectedNodes[0] : undefined } allTypes={ this.state.graph ? this.state.graph.types : [] } onChange={this.forceUpdate} /> <hr /> <h3>Node types</h3> <NodeTypesEditor onChange={this.forceUpdate} graph={this.state.graph} /> <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> ); } }