Skip to content
Snippets Groups Projects
editor.tsx 13.5 KiB
Newer Older
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";
type propTypes = any;
type stateTypes = {
    graph: Graph;
    visibleLabels: 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) {
        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.renderer = React.createRef();
        // Set as new state
        this.state = {
            graph: undefined,
            visibleLabels: true,
            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);
        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));

            graph: newGraph,
        this.state.graph.onChangeCallbacks.push(this.onHistoryChange);

        // Subscribe to global key-press events
        document.onkeydown = this.handleKeyDown;
        document.onkeyup = this.handleKeyUp;
    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() {
    /**
     * 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) {
            // 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(
                this.state.selectedNode.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 {
            graph: this.renderer.current.screen2GraphCoords(
                event.layerX,
                event.layerY
            ),
            window: { x: event.clientX, y: event.clientY },
        };
    }
    private handleNodeClick(node: Node) {
            node.delete();
        } else {
        this.forceUpdate();
    private handleNodeCanvasObject(node: any, ctx: any, globalScale: any) {
        console.log("Custom node rendering");

        // 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.link;

            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): 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);

        ctx.beginPath();
        ctx.moveTo(link.source.x, link.source.y);
        ctx.lineTo(link.target.x, link.target.y);
        ctx.strokeStyle = gradient;
        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.setState({
            selectedNode: node,
        });

        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) {
        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} />
                <div id="content">
                    <div id="sidepanel">
                        <HistoryNavigator
                            spaceId="space"
                            historyObject={this.state.graph}
                            allTypes={
                                this.state.graph ? this.state.graph.types : []
                            }
                            onChange={this.forceUpdate}
                    {this.state.graph ? (
                        <ReactForceGraph2d
                            ref={this.renderer}
                            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}