import React from "react"; import * as Interactions from "../interactions"; import { Graph } from "../structures/graph/graph"; import { loadGraphJson } from "../../../datasets"; import { NodeDetails } from "./nodedetails"; import { SpaceSelect } from "./spaceselect"; import "./editor.css"; import ReactForceGraph2d from "react-force-graph-2d"; 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"; type propTypes = any; type stateTypes = { graph: Graph; visibleLabels: boolean; connectOnDrag: boolean; selectedNode: Node; keys: { [name: string]: boolean }; }; type clickPosition = { graph: { x: number; y: number }; window: { x: number; y: number }; }; type positionTranslate = { x: number; y: number; z: number; }; export class Editor extends React.PureComponent<propTypes, stateTypes> { private maxDistanceToConnect = 15; private defaultWarmupTicks = 100; private warmupTicks = 100; private renderer: any; constructor(props: propTypes) { super(props); 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.handleNodeDragEnd = this.handleNodeDragEnd.bind(this); this.handleLinkClick = this.handleLinkClick.bind(this); this.selectNode = this.selectNode.bind(this); this.renderer = React.createRef(); // Set as new state this.state = { graph: undefined, visibleLabels: true, connectOnDrag: false, selectedNode: undefined, keys: {}, }; Interactions.initInteractions(); // Load initial space this.loadSpace("space"); } /** * 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; } // .onNodeDragEnd((node: any, translate: any) => // Editor.globalState.onNodeDragEnd(node, translate) // ) // .linkWidth((link: any) => Editor.globalState.linkWidth(link)) // .linkDirectionalParticles( // Editor.globalState.linkDirectionalParticles() // ) // .linkDirectionalParticleWidth((link: any) => // Editor.globalState.linkDirectionalParticleWidth(link) // ) // .onBackgroundClick((event: any) => // Editor.globalState.onBackgroundClick( // event, // this.extractPositions(event) // ) // ) // .onLinkClick((link: any) => Editor.globalState.onLinkClick(link)); // Set as new state console.log(newGraph); this.setState({ graph: newGraph, }); this.state.graph.onChangeCallbacks.push(this.onHistoryChange); // Subscribe to global key-press events document.onkeydown = this.handleKeyDown; document.onkeyup = this.handleKeyUp; return true; } private handleKeyDown(event: KeyboardEvent) { const key: string = event.key; const keys = this.state.keys; keys[key] = true; this.setState({ keys: keys, }); } private handleKeyUp(event: KeyboardEvent) { const key: string = event.key; const keys = this.state.keys; keys[key] = false; this.setState({ keys: keys, }); } /** * Handler for background click event on force graph. Adds new node by default. * @param event Click event. */ private handleBackgroundClick(event: any, position: clickPosition) { const newNode = new Node(); newNode.label = "Unnamed"; (newNode as any).fx = position.graph.x; (newNode as any).fy = position.graph.y; (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); } /** * Propagates the changed state of the graph. */ private onHistoryChange() { this.selectNode(undefined); 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.state.selectedNode == undefined || element == undefined) { // Default to false if nothing selected. return false; } if (element.node) { // Is node return element.equals(this.state.selectedNode); } else if (element.link) { // Is link // Is it one of the adjacent links? const found = this.state.selectedNode.links.find(element.equals); return found !== undefined; } 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 }, }; } private selectNode(node: Node) { this.setState({ selectedNode: node, }); } private handleNodeClick(node: Node) { if (this.state.keys["Shift"]) { // Connect two nodes when second select while shift is pressed if (this.state.selectedNode == undefined) { // Have no node connected, so select this.selectNode(node); } else if (!this.state.selectedNode.equals(node)) { const selected = this.state.selectedNode; // Already have *other* node selected, so connect this.state.selectedNode.connect(node); // Re-select original node for easier workflow this.selectNode(selected); } } else if (this.state.keys["Control"]) { // Delete node when control is pressed node.delete(); } else { // By default, simply select node this.selectNode(node); } this.forceUpdate(); } private handleNodeCanvasObject(node: any, ctx: any, globalScale: any) { // add ring just for highlighted nodes if (this.isHighlighted(node)) { ctx.beginPath(); ctx.arc(node.x, node.y, 4 * 0.6, 0, 2 * Math.PI, false); ctx.fillStyle = "red"; 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 if (this.state.visibleLabels) { const label = node.label; 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.x - bckgDimensions[0] / 2, node.y - bckgDimensions[1] / 2 + nodeHeightOffset, ...bckgDimensions ); ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = "white"; ctx.fillText(label, node.x, node.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 = 3; } 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(); // Only render strokes on last link // var lastLink = graph.data[Graph.GRAPH_LINKS][graph.data[Graph.GRAPH_LINKS].length - 1]; // if (link === lastLink) { // ctx.stroke(); // } return undefined; } private handleNodeDrag(node: Node, translate: positionTranslate) { 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(); } private handleNodeDragEnd(node: Node, translate: positionTranslate) { return; } private handleLinkClick(link: Link) { if (this.state.keys["Control"]) { // Delete link when control is pressed link.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(); } render(): React.ReactNode { // The id "ks-editor" indicates, that the javascript associated with this should automatically be executed return ( <div id="ks-editor"> <h1>Interface</h1> <SpaceSelect onLoadSpace={this.loadSpace} /> <SpaceManager /> <div id="content"> <div id="force-graph-renderer"> {this.state.graph ? ( <ReactForceGraph2d ref={this.renderer} width={2000} graphData={this.state.graph.data} onNodeClick={this.handleNodeClick} autoPauseRedraw={false} cooldownTicks={0} warmupTicks={this.warmupTicks} onEngineStop={this.handleEngineStop} nodeCanvasObject={this.handleNodeCanvasObject} nodeCanvasObjectMode={() => "after"} linkCanvasObject={this.handleLinkCanvasObject} linkCanvasObjectMode={() => "replace"} onNodeDrag={this.handleNodeDrag} onNodeDragEnd={this.handleNodeDragEnd} onLinkClick={this.handleLinkClick} onBackgroundClick={(event) => this.handleBackgroundClick( event, this.extractPositions(event) ) } /> ) : undefined} </div> <div id="sidepanel"> <HistoryNavigator spaceId="space" historyObject={this.state.graph} onChange={this.onHistoryChange} /> <hr /> <ul className="instructions"> <li>Click background to create node</li> <li>Click node to select and edit</li> <li>CTRL+Click node to delete</li> <li>CTRL+Click link to delete</li> <li>SHIFT+Click a second node to connect</li> {this.state.connectOnDrag ? ( <li> Drag node close to other node to connect </li> ) : ( "" )} </ul> <hr /> <NodeDetails selectedNode={this.state.selectedNode} 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> </div> </div> </div> ); } }