Skip to content
Snippets Groups Projects
graph.js 15 KiB
Newer Older
  • Learn to ignore specific revisions
  • import * as Config from "../config";
    import * as Helpers from "./helpers";
    
    import { loadGraphJson } from "../datasets/datasets";
    
    import * as THREE from "three";
    import ForceGraph3D from "3d-force-graph";
    
    import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
    import {
        CSS3DRenderer,
        CSS3DSprite,
    } from "three/examples/jsm/renderers/CSS3DRenderer.js";
    
    /**
     * The main ForceGraph. Displays the graph and handles all connected events.
     */
    
    export default class Graph {
    
        /**
         * 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.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);
    
                extraRenderers: [new CSS2DRenderer(), new CSS3DRenderer()],
    
            })(document.getElementById("3d-graph"))
    
                .nodeLabel("id")
                .nodeAutoColorBy("group")
                .nodeColor((node) => this.getNodeColor(node))
                .linkWidth((link) => this.getLinkWidth(link))
                .onNodeClick((node) => {
    
                    this.onNodeHover(node);
                    this.updateHighlight();
                })
    
                .onLinkHover((link) => this.onLinkHover(link))
    
                //.linkColor((link) => this.getLinkColor(link))
                .linkPositionUpdate((line, { start, end }) =>
                    this.updateLinkPosition(line, start, end)
                )
    
                .linkOpacity(0.8)
                .nodeThreeObjectExtend(false)
    
                .nodeThreeObject((node) => this.drawNode(node))
    
                .onEngineTick(() => this.initializeModel())
    
                .width(Helpers.getWidth())
                .height(Helpers.getHeight());
    
        /**
         * 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();
    
        /**
         * Returns the color of the given node as a string in the HTML rgb() format.
         * @param node
         * @returns {string} HTML rgb() string.
         */
    
            return this.highlightNodes.has(node)
    
                    ? "rgb(255,0,0,1)"
                    : "rgba(255,160,0,0.8)"
                : "rgba(0,255,255,0.6)";
    
            if ("type" in link) {
                return this.edgeColors[link.type];
    
        }
    
        getLinkWidth(link) {
            return this.highlightLinks.has(link) ? 2 : 0.8;
        }
    
    
        /**
         * Returns an array containing the different edge types of the graph.
         * @returns {*[]}
         */
    
    Matthias Konitzny's avatar
    Matthias Konitzny committed
        getLinkClasses() {
            const linkClasses = [];
    
    Matthias Konitzny's avatar
    Matthias Konitzny committed
    
    
            this.graph
                .graphData()
                .links.forEach((link) => linkClasses.push(link.type));
    
            return [...new Set(linkClasses)].map((c) => String(c));
        }
    
        getNodeClasses() {
            const nodeClasses = [];
            this.graph
                .graphData()
                .nodes.forEach((node) => nodeClasses.push(node.type));
    
            return [...new Set(nodeClasses)].map((c) => String(c));
    
        onNodeHover(node) {
            // no state change
    
            if (
                (!node && !this.highlightNodes.size) ||
                (node && this.hoverNode === node)
            )
                return;
    
    
            this.highlightNodes.clear();
            this.highlightLinks.clear();
            if (node) {
                this.highlightNodes.add(node);
    
                node.neighbors.forEach((neighbor) =>
                    this.highlightNodes.add(neighbor)
                );
                node.links.forEach((link) => this.highlightLinks.add(link));
    
            }
    
            this.hoverNode = node || null;
        }
    
        onLinkHover(link) {
            this.highlightNodes.clear();
            this.highlightLinks.clear();
    
            if (link) {
                this.highlightLinks.add(link);
                this.highlightNodes.add(link.source);
                this.highlightNodes.add(link.target);
            }
    
            this.updateHighlight();
        }
    
    
    Matthias Konitzny's avatar
    Matthias Konitzny committed
        focusOnNode(node) {
    
            if (typeof node == "string") {
                node = this.idToNode[node];
            }
    
    
    Matthias Konitzny's avatar
    Matthias Konitzny committed
            // Aim at node from outside it
            const distance = 250;
    
            const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
    
    Matthias Konitzny's avatar
    Matthias Konitzny committed
    
            this.graph.cameraPosition(
    
                {
                    x: node.x * distRatio,
                    y: node.y * distRatio,
                    z: node.z * distRatio,
                }, // new position
    
    Matthias Konitzny's avatar
    Matthias Konitzny committed
                node, // lookAt ({ x, y, z })
    
    Matthias Konitzny's avatar
    Matthias Konitzny committed
            );
    
        toggleLinkVisibility(type) {
            if (this.edgeTypeVisibility[type]) {
                this.hideLinkType(type);
            } else {
                this.showLinkType(type);
            }
        }
    
    
        toggleNodeVisibility(type) {
            if (this.nodeTypeVisibility[type]) {
                this.hideNodeType(type);
            } else {
                this.showNodeType(type);
            }
        }
    
        updateVisibility() {
    
            this.updateNodeData();
            this.removeFloatingNodes();
        }
    
    
        hideLinkType(type) {
            this.edgeTypeVisibility[type] = false;
            this.updateVisibility();
        }
    
    
        showLinkType(type) {
            this.edgeTypeVisibility[type] = true;
    
            this.updateVisibility();
        }
    
        hideNodeType(type) {
            this.nodeTypeVisibility[type] = false;
            this.updateVisibility();
        }
    
        showNodeType(type) {
            this.nodeTypeVisibility[type] = true;
            this.updateVisibility();
    
        }
    
        removeFloatingNodes() {
            const gData = this.graph.graphData();
    
            const nodes = gData.nodes.filter((node) => node.neighbors.length > 0);
    
        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);
        }
    
    
                nodes: this.gData.nodes.filter(
                    (l) => this.nodeTypeVisibility[l.type]
                ),
    
                links: this.gData.links.filter(
                    (l) => this.edgeTypeVisibility[l.type]
                ),
    
        /**
         * Resets additional node values.
         * @see updateNodeData
         */
    
        resetNodeData() {
            const gData = this.graph.graphData();
            for (const node of gData.nodes) {
                node.neighbors = [];
                node.links = [];
            }
        }
    
    
        /**
         * Updates the graph data structure to contain additional values.
         * Creates a 'neighbors' and 'links' array for each node object.
         */
    
            const gData = this.graph.graphData();
    
                const a = link.source;
                const b = link.target;
                a.neighbors.push(b);
                b.neighbors.push(a);
                a.links.push(link);
                b.links.push(link);
            });
    
        }
    
        updateHighlight() {
            // trigger update of highlighted objects in scene
            this.graph
                .nodeColor(this.graph.nodeColor())
                .linkWidth(this.graph.linkWidth())
                .linkDirectionalParticles(this.graph.linkDirectionalParticles());
        }
    
    
        updateNodeMap() {
            const gData = this.graph.graphData();
    
                this.idToNode[node.id] = node;
    
    Matthias Konitzny's avatar
    Matthias Konitzny committed
        resize() {
    
            if (document.fullscreenElement == Helpers.getCanvasDivNode()) {
    
    Matthias Konitzny's avatar
    Matthias Konitzny committed
                this.graph.height(screen.height);
                this.graph.width(screen.width);
            } else {
                this.graph.height(window.innerHeight - 200);
    
                this.graph.width(Helpers.getWidth());
    
        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({
    
                map: loader.load(
                    Config.PLUGIN_PATH + "backgrounds/background_4.jpg"
                ),
    
                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 material = new THREE.LineBasicMaterial({
                vertexColors: THREE.VertexColors,
            });
            const geometry = new THREE.BufferGeometry();
            geometry.setAttribute(
                "position",
                new THREE.BufferAttribute(new Float32Array(2 * 3), 3)
            );
            geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
    
            return new THREE.Line(geometry, material);
        }
    
        updateLinkPosition(line, start, end) {
            const startR = 4;
            const endR = 4;
            // const startR = Graph.nodeRelSize();
            // const endR = Graph.nodeRelSize();
            const lineLen = Math.sqrt(
                ["x", "y", "z"]
                    .map((dim) => Math.pow((end[dim] || 0) - (start[dim] || 0), 2))
                    .reduce((acc, v) => acc + v, 0)
            );
    
            const linePos = line.geometry.getAttribute("position");
    
            // calculate coordinate on the node's surface instead of center
            linePos.set(
                [startR / lineLen, 1 - endR / lineLen]
                    .map((t) =>
                        ["x", "y", "z"].map(
                            (dim) => start[dim] + (end[dim] - start[dim]) * t
                        )
                    )
                    .flat()
            );
            linePos.needsUpdate = true;
            return true;
        }
    
    
        drawNode(node) {
            // Draw node as label + image
    
            const nodeDiv = document.createElement("div");
    
    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);
    
    
            // 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);
            }