Skip to content
Snippets Groups Projects
renderer.tsx 10.48 KiB
import * as Config from "../config";
import * as THREE from "three";
import { ForceGraph3D } from "react-force-graph";

import { MODE, DRAG_THRESHOLD_3D } from "../config";
import background from "./background.jpg";

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 Graph, { Coordinate, LinkData, NodeData } from "./graph";

export interface GraphNode extends NodeData {
    x: number;
    y: number;
    z: number;
    vx: number;
    vy: number;
    vz: number;
    fx: number;
    fy: number;
    fz: number;
    color: string;
    __threeObj: THREE.Group;
}

export interface GraphLink extends LinkData {
    __lineObj?: Line2;
}

// It is important to extend from React.PureComponent here to remove unnecessary re-renders!
// see https://www.robinwieruch.de/react-prevent-rerender-component/

/**
 * Renders contents of a Graph object as 3d-graph representation.
 */
export class GraphRenderer extends React.PureComponent<
    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...
    highlightedNodes: Set<GraphNode>;
    highlightedLinks: Set<GraphLink>;
    hoverNode: GraphNode;

    static propTypes = {
        graph: PropTypes.instanceOf(Graph).isRequired,
        width: PropTypes.number.isRequired,
        height: PropTypes.number.isRequired,
        onNodeClicked: PropTypes.func,
        isFullscreen: PropTypes.bool,
    };

    static stateTypes = {};

    constructor(props: InferType<typeof GraphRenderer.propTypes>) {
        super(props);

        this.highlightedNodes = new Set();
        this.highlightedLinks = new Set();
        this.hoverNode = null;
        this.forceGraph = React.createRef();
    }

    componentDidMount() {
        this.addBackground();
    }

    addBackground() {
        const sphereGeometry = new THREE.SphereGeometry(20000, 32, 32);
        const loader = new THREE.TextureLoader();
        const planeMaterial = new THREE.MeshBasicMaterial({
            map: loader.load(background),
            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.forceGraph.current.scene().add(mesh);
    }

    drawNode(node: GraphNode): Object3D {
        const group = new THREE.Group();

        const text = new SpriteText(node.name);
        text.color = "white";
        text.backgroundColor = "black";
        text.textHeight = 5;
        // text.padding = 2;
        text.borderRadius = 5;
        text.borderWidth = 3;
        text.borderColor = "black";
        text.translateY(12);
        text.material.opacity = 0.85;
        text.renderOrder = 999;
        group.add(text);

        // Draw node circle image
        const textureLoader = new THREE.TextureLoader();
        textureLoader.setCrossOrigin("anonymous");
        const imageAlpha = textureLoader.load(
            Config.PLUGIN_PATH + "datasets/images/alpha.png"
        );

        const material = new THREE.SpriteMaterial({
            //map: imageTexture,
            color: this.props.graph.nodeColors.get(node.type),
            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(...Config.NODE_SIZE);

        group.add(sprite);

        return group;
    }

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

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

        const material = new LineMaterial({
            color: 0xffffff,
            linewidth: Config.LINK_WIDTH, // in world units with size attenuation, pixels otherwise
            vertexColors: true,

            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;
    }

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

        const highlightedNodes = new Set<GraphNode>();
        const highlightedLinks = new Set<GraphLink>();

        if (node) {
            highlightedNodes.add(node);
            node.neighbors.forEach((neighbor) =>
                highlightedNodes.add(neighbor as GraphNode)
            );
            node.links.forEach((link) =>
                highlightedLinks.add(link as GraphLink)
            );
        }

        this.hoverNode = node || null;
        this.updateHighlight(highlightedNodes, highlightedLinks);
    }

    onLinkHover(link: GraphLink) {
        const highlightedNodes = new Set<GraphNode>();
        const highlightedLinks = new Set<GraphLink>();

        if (link && link.__lineObj) {
            highlightedLinks.add(link);
            highlightedNodes.add(link.source as GraphNode);
            highlightedNodes.add(link.target as GraphNode);
        }

        this.updateHighlight(highlightedNodes, highlightedLinks);
    }

    updateHighlight(
        highlightedNodes: Set<GraphNode>,
        highlightedLinks: Set<GraphLink>
    ) {
        // Update Links
        this.highlightedLinks.forEach(
            (link) => (link.__lineObj.material.linewidth = Config.LINK_WIDTH)
        );
        this.highlightedLinks = highlightedLinks;
        this.highlightedLinks.forEach(
            (link) =>
                (link.__lineObj.material.linewidth = Config.HOVER_LINK_WIDTH)
        );

        // Update Nodes
        this.highlightedNodes.forEach((node) => {
            node.__threeObj.children[1].scale.set(...Config.NODE_SIZE);
        });
        this.highlightedNodes = highlightedNodes;
        this.highlightedNodes.forEach((node) => {
            node.__threeObj.children[1].scale.set(
                ...Config.HIGHLIGHTED_NODE_SIZE
            );
        });
    }

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

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

    focusOnNode(node: GraphNode) {
        const distance = 400; // Aim at node from outside it
        const speed = 0.5; // Camera travel speed through space
        const minTime = 1000; // Minimum transition time
        const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);

        const currentPos = this.forceGraph.current.cameraPosition();
        const newPos = {
            x: node.x * distRatio,
            y: node.y * distRatio,
            z: node.z * distRatio,
        };
        const travelDist = Math.hypot(
            newPos.x - currentPos.x,
            newPos.y - currentPos.y,
            newPos.z - currentPos.z
        );

        this.forceGraph.current.cameraPosition(
            newPos,
            node, // lookAt ({ x, y, z })
            Math.max(travelDist / speed, minTime) // ms transition duration
        );
    }

    updateLinkPosition(line: Line2, start: Coordinate, end: Coordinate) {
        if (!(line instanceof Line2)) {
            return false;
        }
        // console.log("Updating links");
        const startR = 4;
        const endR = 4;
        const lineLen = Math.hypot(
            end.x - start.x,
            end.y - start.y,
            end.z - start.z
        );

        const positions = [startR / lineLen, 1 - endR / lineLen]
            .map((t) =>
                ["x", "y", "z"].map(
                    (dim) =>
                        start[dim as keyof typeof start] +
                        (end[dim as keyof typeof end] -
                            start[dim as keyof typeof start]) *
                            t
                )
            )
            .flat();
        line.geometry.setPositions(positions);

        return true;
    }

    render() {
        return (
            <ForceGraph3D
                ref={this.forceGraph}
                width={this.props.width}
                height={this.props.height}
                graphData={this.props.graph}
                rendererConfig={{ antialias: true }}
                // nodeLabel={"hidden"}
                nodeThreeObject={(node: GraphNode) => this.drawNode(node)}
                linkThreeObject={(link: LinkData) => this.drawLink(link)}
                onNodeClick={(node: GraphNode) => this.onNodeClick(node)}
                //d3AlphaDecay={0.1}
                warmupTicks={150}
                cooldownTime={1000} // TODO: Do we want the simulation to unfreeze on node drag?
                enableNodeDrag={false}
                onNodeHover={(node: GraphNode) => this.onNodeHover(node)}
                onLinkHover={(link: GraphLink) => this.onLinkHover(link)}
                linkPositionUpdate={(
                    line: Line2,
                    coords: { start: Coordinate; end: Coordinate }
                ) => this.updateLinkPosition(line, coords.start, coords.end)}
                onNodeDragEnd={(node: GraphNode, translate: Coordinate) =>
                    this.onNodeDragEnd(node, translate)
                }
            />
        );
    }
}