diff --git a/src/common/graph/graph.ts b/src/common/graph/graph.ts index 855cfe14c89585ef6aa66a858aa256f0fcf67a96..80715515d0b428e1784b30565e4348ac9e9b5a73 100644 --- a/src/common/graph/graph.ts +++ b/src/common/graph/graph.ts @@ -4,9 +4,12 @@ import { Link, LinkData, SimLinkData } from "./link"; import { NodeType, NodeTypeData } from "./nodetype"; import { SerializableItem } from "../serializableitem"; -export interface Coordinate { +export interface Coordinate2D { x: number; y: number; +} + +export interface Coordinate3D extends Coordinate2D { z: number; } diff --git a/src/display/renderer.tsx b/src/display/renderer.tsx index 2ad9ef2480ff478429bc8e5b878fd0e741d59a33..523b028183cf0f28d0de0486825b659612bb35aa 100644 --- a/src/display/renderer.tsx +++ b/src/display/renderer.tsx @@ -10,7 +10,7 @@ import React from "react"; import PropTypes, { InferType } from "prop-types"; import SpriteText from "three-spritetext"; import { Object3D, Sprite } from "three"; -import { Graph, Coordinate } from "../common/graph/graph"; +import { Graph, Coordinate3D } from "../common/graph/graph"; import { Node } from "../common/graph/node"; import { Link } from "../common/graph/link"; @@ -287,7 +287,7 @@ export class GraphRenderer extends React.PureComponent< this.displayNodeSelection(node); } - onNodeDragEnd(node: VisualGraphNode, translate: Coordinate) { + onNodeDragEnd(node: VisualGraphNode, translate: Coordinate3D) { // NodeDrag is handled like NodeClick if distance is very short if ( Math.hypot(translate.x, translate.y, translate.z) < @@ -322,7 +322,7 @@ export class GraphRenderer extends React.PureComponent< ); } - updateLinkPosition(line: Line2, start: Coordinate, end: Coordinate) { + updateLinkPosition(line: Line2, start: Coordinate3D, end: Coordinate3D) { if (!(line instanceof Line2)) { return false; } @@ -373,11 +373,12 @@ export class GraphRenderer extends React.PureComponent< onLinkHover={(link: VisualGraphLink) => this.onLinkHover(link)} linkPositionUpdate={( line: Line2, - coords: { start: Coordinate; end: Coordinate } + coords: { start: Coordinate3D; end: Coordinate3D } ) => this.updateLinkPosition(line, coords.start, coords.end)} - onNodeDragEnd={(node: VisualGraphNode, translate: Coordinate) => - this.onNodeDragEnd(node, translate) - } + onNodeDragEnd={( + node: VisualGraphNode, + translate: Coordinate3D + ) => this.onNodeDragEnd(node, translate)} /> ); } diff --git a/src/editor/components/historynavigator.tsx b/src/editor/components/historynavigator.tsx index f9de09cf07ee1e07e48bf16312a67d1f962b6969..4fd76f9bbd775f1d3c9c83cd8fc87abd648777c1 100644 --- a/src/editor/components/historynavigator.tsx +++ b/src/editor/components/historynavigator.tsx @@ -37,6 +37,8 @@ export class HistoryNavigator extends React.Component<propTypes> { * Saves current data of history object. */ handleSave() { + this.props.history.createCheckpoint("Saved graph."); + saveGraphJson( this.props.spaceId, this.props.history.currentCheckpoint.data @@ -46,6 +48,7 @@ export class HistoryNavigator extends React.Component<propTypes> { alert( "Saved! (Though not for sure, currently not checking for success of failure)" ); + this.forceUpdate(); } /** diff --git a/src/editor/components/instructions.tsx b/src/editor/components/instructions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1e604ee67c8e82b4e0178b03f7b4ade433cf856a --- /dev/null +++ b/src/editor/components/instructions.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +interface InstructionsProps { + connectOnDragEnabled: boolean; +} + +function Instructions({ connectOnDragEnabled }: InstructionsProps) { + return ( + <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> + {connectOnDragEnabled ? ( + <li>Drag node close to other node to connect</li> + ) : ( + "" + )} + <li>DELETE to delete selected nodes</li> + <li>ESCAPE to clear selection</li> + </ul> + ); +} + +export default Instructions; diff --git a/src/editor/components/nodedetails.tsx b/src/editor/components/nodedetails.tsx index 37d5a96029b54cb101b84aef38979e1806c14d7f..9db2e920eeb2edc5f0fb6c96aa7345b178676e24 100644 --- a/src/editor/components/nodedetails.tsx +++ b/src/editor/components/nodedetails.tsx @@ -88,7 +88,6 @@ export class NodeDetails extends React.Component<propTypes> { this.props.onChange(); // Save change, but debounce, so it doesn't trigger too quickly - this.props.onChange(); // this.debounce( // (property: string) => { // // this.props.selectedNodes[0].graph.storeCurrentData( TODO: Reimplement diff --git a/src/editor/components/selectlayer.tsx b/src/editor/components/selectlayer.tsx index d638abceef0198b4745204a04d9c608c85d9cb4e..9b7872d5d1a5d87e9e43d43765272318a054b85a 100644 --- a/src/editor/components/selectlayer.tsx +++ b/src/editor/components/selectlayer.tsx @@ -6,7 +6,7 @@ import "./selectlayer.css"; type propTypes = { children: any; allNodes: Node[]; - isEnable: () => boolean; + isEnabled: boolean; screen2GraphCoords: (x: number, y: number) => any; onBoxSelect: (nodes: Node[]) => void; }; @@ -50,7 +50,7 @@ export class SelectLayer extends React.Component<propTypes> { return false; } - if (!this.props.isEnable()) { + if (!this.props.isEnabled) { this.initialSelectPoint = undefined; this.layerBox.current.className = ""; return false; @@ -80,7 +80,7 @@ export class SelectLayer extends React.Component<propTypes> { } boxSelectOnPointerDown(e: any) { - if (!this.props.isEnable()) { + if (!this.props.isEnabled) { return; } diff --git a/src/editor/components/settings.tsx b/src/editor/components/settings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8abfef101dde6aa177c9ed5efca62361f66befd1 --- /dev/null +++ b/src/editor/components/settings.tsx @@ -0,0 +1,43 @@ +import React from "react"; + +interface SettingsProps { + labelVisibility: boolean; + onLabelVisibilityChange: (state: boolean) => void; + + connectOnDrag: boolean; + onConnectOnDragChange: (state: boolean) => void; +} + +function Settings({ + labelVisibility, + onLabelVisibilityChange, + connectOnDrag, + onConnectOnDragChange, +}: SettingsProps) { + return ( + <div className={"SettingsMenu"}> + <h3>Settings</h3> + <input + id="node-label-visibility" + type={"checkbox"} + checked={labelVisibility} + onChange={(event) => { + onLabelVisibilityChange(event.target.checked); + }} + /> + <label htmlFor="node-label-visibility">Node labels</label> + <br /> + <input + id="connect-on-drag" + type={"checkbox"} + checked={connectOnDrag} + onChange={(event) => { + onConnectOnDragChange(event.target.checked); + }} + /> + <label htmlFor="connect-on-drag">Connect nodes when dragged</label> + </div> + ); +} + +export default Settings; diff --git a/src/editor/editor.tsx b/src/editor/editor.tsx index b50a9f516d68d37500b912e7d5994ff09214e2ff..be8e6e115ec3e4c2ce2b7f6f313436539ee5ea09 100644 --- a/src/editor/editor.tsx +++ b/src/editor/editor.tsx @@ -4,16 +4,17 @@ 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"; +import { GraphRenderer2D } from "./renderer"; +import Instructions from "./components/instructions"; +import Settings from "./components/settings"; type propTypes = { spaceId: string; @@ -35,9 +36,9 @@ type stateTypes = { connectOnDrag: boolean; /** - * Collection of all currently selected nodes. Can also be undefined or empty. + * Current width of graph object. Used to specifically adjust and correct the graph size. */ - selectedNodes: Node[]; + 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. @@ -45,40 +46,17 @@ type stateTypes = { keys: { [name: string]: boolean }; /** - * Current width of graph object. Used to specifically adjust and correct the graph size. + * Collection of all currently selected nodes. Can also be undefined or empty. */ - 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; + selectedNodes: Node[]; }; /** * 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; + private rendererRef: React.RefObject<GraphRenderer2D>; constructor(props: propTypes) { super(props); @@ -86,24 +64,18 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { // 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.handleBoxSelect = this.handleBoxSelect.bind(this); + this.selectNodes = this.selectNodes.bind(this); + + document.addEventListener("keydown", (e) => { + this.keyPressed(e.key); + }); + document.addEventListener("keyup", (e) => { + this.keyReleased(e.key); + }); - this.renderer = React.createRef(); this.graphContainer = React.createRef(); // Set as new state @@ -111,12 +83,24 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { graph: undefined, visibleLabels: true, connectOnDrag: false, - selectedNodes: [], // TODO: Why was undefined allowed here? - keys: {}, graphWidth: 1000, + selectedNodes: [], + keys: {}, }; } + 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. */ @@ -149,98 +133,22 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { 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); + //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; + window.addEventListener("resize", () => 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. */ @@ -251,96 +159,6 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { }); } - /** - * 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. // */ @@ -362,175 +180,22 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { // 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) { + handleBoxSelect(selectedNodes: Node[]) { + if (selectedNodes !== undefined && 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 + this.selectNodes(selectedNodes.concat(this.state.selectedNodes)); } - 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(); + /** + * 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) { @@ -540,67 +205,6 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { 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"> @@ -608,62 +212,43 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { <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 && ( + <div + id="force-graph-renderer" + ref={this.graphContainer} > - {this.state.graph ? ( - <ForceGraph2D - ref={this.renderer} + <SelectLayer + allNodes={ + this.state.graph + ? this.state.graph.nodes + : [] + } + screen2GraphCoords={ + this.rendererRef + ? 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} - 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) - ) - } + onNodeSelectionChanged={this.selectNodes} + selectedNodes={this.state.selectedNodes} /> - ) : undefined} - </SelectLayer> - </div> + </SelectLayer> + </div> + )} {this.state.graph && ( <div id="sidepanel"> - <HistoryNavigator - spaceId="space" - history={this.state.graph.history} - onChange={this.onGraphDataChange} - /> + {/*<HistoryNavigator*/} + {/* spaceId="space"*/} + {/* history={this.state.graph.history}*/} + {/* onChange={this.onGraphDataChange}*/} + {/*/>*/} <hr /> <NodeDetails selectedNodes={this.state.selectedNodes} @@ -682,72 +267,23 @@ export class Editor extends React.PureComponent<propTypes, stateTypes> { 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; - } - + <Settings + labelVisibility={this.state.visibleLabels} + onLabelVisibilityChange={(visible) => + this.setState({ visibleLabels: visible }) + } + connectOnDrag={this.state.connectOnDrag} + onConnectOnDragChange={(connectOnDrag) => this.setState({ - visibleLabels: newValue, - }); - }} + connectOnDrag: connectOnDrag, + }) + } /> - <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; - } + <hr /> - this.setState({ - connectOnDrag: newValue, - }); - }} + <Instructions + connectOnDragEnabled={this.state.connectOnDrag} /> - <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> diff --git a/src/editor/renderer.tsx b/src/editor/renderer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..325b47605e085e444d61f5b13c5329e00501d2d9 --- /dev/null +++ b/src/editor/renderer.tsx @@ -0,0 +1,429 @@ +import React from "react"; +import PropTypes, { InferType } from "prop-types"; +import { DynamicGraph } from "./graph"; +import { Node } from "../common/graph/node"; +import { ForceGraph2D } from "react-force-graph"; +import { Link } from "../common/graph/link"; +import { GraphElement } from "../common/graph/graphelement"; +import { Coordinate2D } from "../common/graph/graph"; + +export class GraphRenderer2D extends React.PureComponent< + InferType<typeof GraphRenderer2D.propTypes>, + InferType<typeof GraphRenderer2D.stateTypes> +> { + private maxDistanceToConnect = 15; + private defaultWarmupTicks = 100; + private warmupTicks = 100; + private forceGraph: React.RefObject<any>; // using typeof ForceGraph3d produces an error here... + + /** + * True, if the graph was the target of the most recent click event. + */ + private graphInFocus = false; // TODO: Remove? + /** + * True for each key, that is currently considered pressed. If key has not been pressed yet, it will not exist as dict-key. + */ + private keys: { [name: string]: boolean }; + + static propTypes = { + graph: PropTypes.instanceOf(DynamicGraph).isRequired, + width: PropTypes.number.isRequired, + onNodeClicked: PropTypes.func, + onNodeSelectionChanged: PropTypes.func, + /** + * Collection of all currently selected nodes. Can also be undefined or empty. + */ + selectedNodes: PropTypes.arrayOf(PropTypes.instanceOf(Node)), + }; + + static stateTypes = {}; + + constructor(props: InferType<typeof GraphRenderer2D.propTypes>) { + super(props); + + this.screen2GraphCoords = this.screen2GraphCoords.bind(this); + this.handleNodeCanvasObject = this.handleNodeCanvasObject.bind(this); + this.handleLinkCanvasObject = this.handleLinkCanvasObject.bind(this); + + document.addEventListener("keydown", (e) => { + this.keys[e.key] = true; + this.handleShortcutEvents(e.key); + }); + document.addEventListener("keyup", (e) => { + this.keys[e.key] = false; + this.handleShortcutEvents(e.key); + }); + document.addEventListener( + "mousedown", + (e) => (this.graphInFocus = false) + ); + + this.state = { + selectedNodes: [], // TODO: Why was undefined allowed here? + }; + + this.forceGraph = React.createRef(); + } + + /** + * 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.props.onNodeSelectionChanged(selectedNodes); + } else { + selectedNodes.forEach((node: Node) => node.delete()); + this.props.onNodeSelectionChanged([]); + } + } + + /** + * Triggers actions that correspond with certain shortcuts. + * + * @param key Newly pressed key. + */ + private handleShortcutEvents(key: string) { + if (key === "Escape") { + this.props.onNodeSelectionChanged([]); + } else if ( + key === "Delete" && + this.graphInFocus // Only delete if 2d-graph is the focused element + ) { + this.deleteSelectedNodes(); + } + } + + 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.props.onNodeSelectionChanged([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.props.onNodeSelectionChanged([node]); + } + //this.forceUpdate(); // TODO: Remove? + } + + /** + * Handler for background click event on force graph. Adds new node by default. + * @param event Click event. + */ + private handleBackgroundClick( + event: MouseEvent, + position: { graph: Coordinate2D; window: Coordinate2D } + ) { + 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.props.onNodeSelectionChanged([]); + 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.props.onNodeSelectionChanged([node]); + } + } + + /** + * 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? + } + + /** + * 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.props.onNodeSelectionChanged(nodes); + this.forceUpdate(); // TODO + } + + 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.props.onNodeSelectionChanged(selection); + } + + 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 + } + + private handleNodeDrag(node: Node) { + this.graphInFocus = true; + + if ( + !this.state.selectedNodes || + !this.state.selectedNodes.includes(node) + ) { + this.props.onNodeSelectionChanged([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(); TODO: Remove? + } + + 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.props.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(); + } + + public screen2GraphCoords(x: number, y: number): Coordinate2D { + return this.forceGraph.current.screen2GraphCoords(x, y); + } + + /** + * 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): { + graph: Coordinate2D; + window: Coordinate2D; + } { + return { + graph: this.screen2GraphCoords( + event.layerX, // TODO: Replace layerx/layery non standard properties and fix typing + event.layerY + ), + window: { x: event.clientX, y: event.clientY }, + }; + } + + render() { + this.warmupTicks = this.defaultWarmupTicks; + return ( + <ForceGraph2D + ref={this.forceGraph} + width={this.props.width} + graphData={this.props.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) + ) + } + /> + ); + } +}