Skip to content
Snippets Groups Projects
renderer.tsx 18 KiB
Newer Older
import * as Config from "../config";
import * as Helpers from "./helpers";
import { loadGraphJson } from "../datasets";
import { ForceGraph3D } from "react-force-graph";
import screenfull from "screenfull";
import {
    CSS3DRenderer,
    CSS3DSprite,
} from "three/examples/jsm/renderers/CSS3DRenderer.js";
import { MODE, DRAG_THRESHOLD_3D } from "../config";
import { Line2, LineGeometry, LineMaterial } from "three-fatline";
import React from "react";
import PropTypes, { InferType } from "prop-types";
import SpriteText from "three-spritetext";
import { Object3D } from "three";
import {
    Coordinate,
    Graph,
    GraphLink,
    GraphNode,
    LinkData,
    NodeData,
} from "./graph";
import { graph } from "../editor/js/editor";

export class GraphRenderer extends React.Component<
    InferType<typeof GraphRenderer.propTypes>,
    InferType<typeof GraphRenderer.stateTypes>
> {
    props: InferType<typeof GraphRenderer.propTypes>;
    state: InferType<typeof GraphRenderer.stateTypes>;
    forceGraph: React.RefObject<any>; // using typeof ForceGraph3d produces an error here...
    edgeColors: Map<string, string>;

    static propTypes = {
        graph: PropTypes.instanceOf(Graph),
        spaceId: PropTypes.string.isRequired,
        loadingFinishedCallback: PropTypes.func,
        onNodeClicked: PropTypes.func,
        isFullscreen: PropTypes,
    };

    static stateTypes = {
        highlightedNodes: PropTypes.array,
        highlightedLinks: PropTypes.array,
        hoverNode: PropTypes.object,
    };

    constructor(props: InferType<typeof GraphRenderer.propTypes>) {
        super(props);
        this.state = {
            highlightedNodes: [],
            highlightedLinks: [],
            hoverNode: null,
        };
        this.forceGraph = React.createRef();
        // TODO: NodeVisibility, linkVisibility, graphLoading has to be moved to parent component
    }

    // componentDidMount() {
    //     loadGraphJson(this.props.spaceId).then((json) =>
    //         this.updateGraph(json)
    //     );
    // }

    // updateGraph(json: { nodes: NodeData[]; links: LinkData[] }) {
    //     const graph = new Graph(json.nodes, json.links);
    //     this.setState({ graph: graph });
    //
    //     this.edgeVisibility = new Map<string, boolean>(
    //         graph.getLinkClasses().map((cls) => [cls, true])
    //     );
    //     this.nodeVisibility = new Map<string, boolean>(
    //         graph.getNodeClasses().map((cls) => [cls, true])
    //     );
    // }

    drawNode(node: GraphNode): Object3D {
        const sprite = new SpriteText(node.id);
        sprite.color = node.color;
        sprite.textHeight = 8;
        return sprite;
    }

    onNodeHover(node: GraphNode) {
        // no state change
        if (
            (!node && !this.state.highlightNodes.size) ||
            (node && this.state.hoverNode === node)
        )
            return;

        const highlightNodes: Set<NodeData> = new Set<NodeData>();
        const highlightLinks: Set<LinkData> = new Set<LinkData>();

        if (node) {
            highlightNodes.add(node);
            node.neighbors.forEach((neighbor) => highlightNodes.add(neighbor));
            node.links.forEach((link) => highlightLinks.add(link));
        }

        this.setState({
            highlightedNodes: highlightNodes,
            highlightedLinks: highlightLinks,
            hoverNode: node || null,
        });
    }

    onLinkHover(link: GraphLink, previousLink: GraphLink) {
        const highlightNodes: Set<NodeData> = new Set<NodeData>();
        const highlightLinks: Set<LinkData> = new Set<LinkData>();

        if (previousLink && previousLink.material) {
            // A bit hacky, but the alternative would require additional data structures
            previousLink.material.linewidth = Config.LINK_WIDTH;
        }

        if (link && link.material) {
            link.material.linewidth = Config.HOVER_LINK_WIDTH;

            highlightLinks.add(link);
            highlightNodes.add(link.source);
            highlightNodes.add(link.target);
        }

        this.setState({
            highlightedNodes: highlightNodes,
            highlightedLinks: highlightLinks,
        });
    }

    onNodeClick(node: GraphNode) {
        this.focusOnNode(node);
        if (MODE === "default") {
            this.props.onNodeClicked(node);
        }
    }

    onNodeDragEnd(node: GraphNode, translate: Coordinate) {
        // NodeDrag is handled like NodeClick if distance is very short
        if (
            Math.sqrt(
                Math.pow(translate.x, 2) +
                    Math.pow(translate.y, 2) +
                    Math.pow(translate.z, 2)
            ) < DRAG_THRESHOLD_3D
        ) {
            this.onNodeClick(node);
        }
    }

    focusOnNode(node: GraphNode) {
        // Aim at node from outside it
        const distance = 250;
        const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);

        this.forceGraph.current.cameraPosition(
            {
                x: node.x * distRatio,
                y: node.y * distRatio,
                z: node.z * distRatio,
            }, // new position
            node, // lookAt ({ x, y, z })
            1000 // ms transition duration
        );
    }

    getLinkColor(link: LinkData) {
        if ("type" in link) {
            return this.edgeColors.get(link.type);
        }
        return "rgb(255, 255, 255)";
    }

    getLinkWidth(link) {
        return this.highlightLinks.has(link) ? 2 : 0.8;
    }

    /**
     * Maps the colors of the color palette to the different edge types
     */
    mapLinkColors() {
        const linkClasses = this.props.graph.getLinkClasses();
        for (let i = 0; i < linkClasses.length; i++) {
            this.edgeColors.set(
                linkClasses[i],
                Config.COLOR_PALETTE[i % Config.COLOR_PALETTE.length]
            );
        }
    }

    resize() {
        if (screenfull.isFullscreen) {
            this.graph.height(screen.height);
            this.graph.width(screen.width);
        } else {
            this.graph.height(window.innerHeight - 200);
            this.graph.width(Helpers.getWidth());
        }
    }

    render() {
        this.mapLinkColors();

        return (
            <ForceGraph3D
                ref={this.forceGraph}
                width={scree}
                graphData={this.state.graph}
                rendererConfig={{ antialias: true }}
                nodeThreeObject={(node: GraphNode) => this.drawNode(node)}
                onNodeClick={(node: GraphNode) => this.onNodeClick(node)}
                onNodeHover={(node: GraphNode) => this.onNodeHover(node)}
                onLinkHover={(link: GraphLink, previousLink: GraphLink) =>
                    this.onLinkHover(link, previousLink)
                }
                onNodeDragEnd={(node: GraphNode, translate: Coordinate) =>
                    this.onNodeDragEnd(node, translate)
                }
            />
        );
    }
}
/**
 * The main ForceGraph. Displays the graph and handles all connected events.
 */
export default class Graph2 {
    /**
     * Constructs a new Graph object.
     * @param {string} spaceId Name of the knowledge space that should be loaded
     * @param {function} loadingFinishedCallback Callback that is called when the graph is fully loaded.
    constructor(spaceId, loadingFinishedCallback = Function()) {
        this.highlightNodes = new Set();
        this.highlightLinks = new Set();
        this.hoverNode = null;
Matthias Konitzny's avatar
Matthias Konitzny committed
        this.edgeColors = {};
        this.idToNode = {};
        this.engineFrozen = false;
        this.allowRedraw = false;
        this.loadingFinishedCallback = loadingFinishedCallback;

        this.loadGraph(spaceId);
    /**
     * Loads the graph by constructing a new ForceGraph3D object.
     * Also fetches the JSON data from the given space.
     * @param {string} spaceId ID to a JSON object defining the graph structure.
     * @returns {Promise<void>}
     */
    async loadGraph(spaceId) {
        this.gData = await loadGraphJson(spaceId);
        })(document.getElementById("3d-graph"))
            .nodeLabel("hidden") // Just a value that is not present as node attribute.
Matthias Konitzny's avatar
Matthias Konitzny committed
            //.nodeAutoColorBy("group")
            //.nodeColor((node) => this.getNodeColor(node))
            //.linkWidth((link) => this.getLinkWidth(link))
            .onNodeClick((node) => this.onNodeClick(node))
                this.onNodeHover(node);
                this.updateHighlight();
            })
Matthias Konitzny's avatar
Matthias Konitzny committed
            .onLinkHover((link, previousLink) =>
                this.onLinkHover(link, previousLink)
            )
            .onNodeDragEnd((node, translate) =>
                this.onNodeDragEnd(node, translate)
            )
            .onEngineStop(() => this.simulationStop())
            //.linkColor((link) => this.getLinkColor(link))
            .linkPositionUpdate((line, { start, end }) =>
                this.updateLinkPosition(line, start, end)
            )
Matthias Konitzny's avatar
Matthias Konitzny committed
            //.linkOpacity(0.8)
            .nodeThreeObject((node) => this.drawNode(node))
Matthias Konitzny's avatar
Matthias Konitzny committed
            //.linkThreeObject((link) => this.drawLink(link))
            .onEngineTick(() => this.initializeModel())
            .width(Helpers.getWidth())
            .height(Helpers.getHeight());

        setTimeout(() => this.simulationStop(), 3000);
    /**
     * Initializes all component which are dependent on the graph data after the graph has finished loading
     * (after it has computed its first tick.)
     */
Matthias Konitzny's avatar
Matthias Konitzny committed
    initializeModel() {
        if (this.firstTick) {
            // Initialize data structures
            this.mapLinkColors();
            this.mapNodeColors();
            this.updateNodeMap();
Matthias Konitzny's avatar
Matthias Konitzny committed
            this.addBackground();

            // Can only be called after link colors have been mapped.
            this.graph.linkThreeObject((link) => this.drawLink(link));

Matthias Konitzny's avatar
Matthias Konitzny committed
            // Catch resize events
            document.addEventListener("fullscreenchange", () => this.resize());
            window.addEventListener("resize", () => this.resize());
            this.getLinkClasses().forEach(
                (item) => (this.edgeTypeVisibility[item] = true)
            );
            this.getNodeClasses().forEach(
                (item) => (this.nodeTypeVisibility[item] = true)
            );
Matthias Konitzny's avatar
Matthias Konitzny committed
            this.firstTick = false;
            this.loadingFinishedCallback();
    // TODO: Move this up to the class which is handling the graph
    // updateVisibility() {
    //     this.updateGraphData();
    //     this.removeFloatingLinks();
    //     this.updateNodeData();
    //     this.removeFloatingNodes();
    // }
    //
    // removeFloatingNodes() {
    //     const gData = this.graph.graphData();
    //     const nodes = gData.nodes.filter((node) => node.neighbors.length > 0);
    //     const data = {
    //         nodes: nodes,
    //         links: gData.links,
    //     };
    //     this.graph.graphData(data);
    // }
    //
    // removeFloatingLinks() {
    //     const gData = this.graph.graphData();
    //     const links = gData.links.filter(
    //         (link) =>
    //             this.nodeTypeVisibility[link.target.type] &
    //             this.nodeTypeVisibility[link.source.type]
    //     );
    //     const data = {
    //         nodes: gData.nodes,
    //         links: links,
    //     };
    //     this.graph.graphData(data);
    // }

    // /**
    //  * Resets additional node values.
    //  * @see updateNodeData
    //  */

    updateHighlight() {
        // trigger update of highlighted objects in scene
Matthias Konitzny's avatar
Matthias Konitzny committed
        // this.graph
        //     .nodeColor(this.graph.nodeColor())
        //     .linkWidth(this.graph.linkWidth())
        //     .linkDirectionalParticles(this.graph.linkDirectionalParticles());
    }

    addBackground() {
        const sphereGeometry = new THREE.SphereGeometry(20000, 32, 32);
        //const planeGeometry = new THREE.PlaneGeometry(1000, 1000, 1, 1);
        const loader = new THREE.TextureLoader();
        //const planeMaterial = new THREE.MeshLambertMaterial({color: 0xFF0000, side: THREE.DoubleSide}); //THREE.BackSide
        const planeMaterial = new THREE.MeshBasicMaterial({
            side: THREE.DoubleSide,
        }); //THREE.BackSide
        const mesh = new THREE.Mesh(sphereGeometry, planeMaterial);
        mesh.position.set(0, 0, 0);
        //mesh.rotation.set(0.5 * Math.PI, 0, 0);

        this.graph.scene().add(mesh);
    }

    /**
     * Maps the colors of the color palette to the different edge types
     */
        const linkClasses = this.getLinkClasses();
Matthias Konitzny's avatar
Matthias Konitzny committed
        for (let i = 0; i < linkClasses.length; i++) {
            this.edgeColors[linkClasses[i]] =
                Config.COLOR_PALETTE[i % Config.COLOR_PALETTE.length];
    mapNodeColors() {
        const nodeClasses = this.getNodeClasses();
        for (let i = 0; i < nodeClasses.length; i++) {
            this.nodeColors[nodeClasses[i]] =
                Config.COLOR_PALETTE[i % Config.COLOR_PALETTE.length];
        }
    }

    drawLink(link) {
        const colors = new Float32Array(
            [].concat(
                ...[link.target, link.source]
                    .map((node) => this.nodeColors[node.type])
                    .map((color) => color.replace(/[^\d,]/g, "").split(",")) // Extract rgb() color components
                    .map((rgb) => rgb.map((v) => v / 255))
            )
        );

        const geometry = new LineGeometry();
        geometry.setPositions([0, 0, 0, 1, 1, 1]);
        geometry.setColors(colors);

        const material = new LineMaterial({
            color: 0xffffff,
Matthias Konitzny's avatar
Matthias Konitzny committed
            linewidth: Config.LINK_WIDTH, // in world units with size attenuation, pixels otherwise
Matthias Konitzny's avatar
Matthias Konitzny committed
            resolution: new THREE.Vector2(
                window.screen.width,
                window.screen.height
            ), // Set the resolution to the maximum width and height of the screen.
            dashed: false,
            alphaToCoverage: true,
        const line = new Line2(geometry, material);
        line.computeLineDistances();
        line.scale.set(1, 1, 1);
        return line;
    simulationStop() {
        this.engineFrozen = true;
        this.stopPhysics();
    }

    stopPhysics() {
        const data = this.graph.graphData();
        data["nodes"].forEach((n) => {
            n.fx = n.x;
            n.fy = n.y;
            n.fz = n.z;
        });
        this.graph.graphData(data);
    }

        if (!this.allowRedraw) {
            if (this.engineFrozen) {
                return true;
            }
        }

        if (!(line instanceof Line2)) {
            return false;
        }

        const startR = 4;
        const endR = 4;
        const lineLen = Math.sqrt(
            ["x", "y", "z"]
                .map((dim) => Math.pow((end[dim] || 0) - (start[dim] || 0), 2))
                .reduce((acc, v) => acc + v, 0)
        );

Matthias Konitzny's avatar
Matthias Konitzny committed
        const positions = [startR / lineLen, 1 - endR / lineLen]
            .map((t) =>
                ["x", "y", "z"].map(
                    (dim) => start[dim] + (end[dim] - start[dim]) * t
Matthias Konitzny's avatar
Matthias Konitzny committed
        line.geometry.setPositions(positions);
        // line.geometry.getAttribute("position").needsUpdate = true;
        // line.computeLineDistances();
    drawNode(node) {
        // Draw node as label + image
        const nodeDiv = Helpers.createDiv(
            "node-container",
            document.getElementById("3d-graph")
        );
Matthias Konitzny's avatar
Matthias Konitzny committed
        const labelDiv = Helpers.createDiv("node-label", nodeDiv, {
            textContent: node.name,
        });
        labelDiv.classList.add("no-select");
        labelDiv.style.color = node.color;

        const cssobj = new CSS3DSprite(nodeDiv);
        cssobj.scale.set(0.25, 0.25, 0.25);
        cssobj.position.set(0, -6, 0);
        cssobj.element.style.pointerEvents = "none";

        // Draw node circle image
        const textureLoader = new THREE.TextureLoader();
        textureLoader.setCrossOrigin("anonymous");
        const imageAlpha = textureLoader.load(
            Config.PLUGIN_PATH + "datasets/images/alpha.png"
            if (node.image.startsWith("http")) {
                imageTexture = textureLoader.load(node.image);
            } else {
                imageTexture = textureLoader.load(
                    Config.PLUGIN_PATH + "datasets/images/" + node.image
                );
            }
                Config.PLUGIN_PATH + "datasets/images/default.jpg"
        const material = new THREE.SpriteMaterial({
            map: imageTexture,
            alphaMap: imageAlpha,
            transparent: true,
            alphaTest: 0.2,
            depthWrite: false,
            depthTest: false,
        });
        const sprite = new THREE.Sprite(material);
        sprite.renderOrder = 999; // This may not be optimal. But it allows us to render the sprite on top of everything else.

            sprite.scale.set(20, 20);
        } else {
            sprite.scale.set(5, 5);
        }