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.graph = null; this.gData = null; this.highlightNodes = new Set(); this.highlightLinks = new Set(); this.hoverNode = null; this.edgeColors = {}; this.nodeColors = {}; this.idToNode = {}; this.firstTick = true; this.infoOverlay = null; this.edgeTypeVisibility = {}; this.nodeTypeVisibility = {}; 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); this.graph = ForceGraph3D({ extraRenderers: [new CSS2DRenderer(), new CSS3DRenderer()], rendererConfig: { antialias: true }, })(document.getElementById("3d-graph")) .graphData(this.gData) .nodeLabel("id") .nodeAutoColorBy("group") .nodeColor((node) => this.getNodeColor(node)) .linkWidth((link) => this.getLinkWidth(link)) .onNodeClick((node) => { this.focusOnNode(node); this.infoOverlay.updateInfoOverlay(node); }) .onNodeHover((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.) */ initializeModel() { if (this.firstTick) { // Initialize data structures this.mapLinkColors(); this.mapNodeColors(); this.updateNodeData(); this.updateNodeMap(); this.addBackground(); // Can only be called after link colors have been mapped. this.graph.linkThreeObject((link) => this.drawLink(link)); // Catch resize events document.addEventListener("fullscreenchange", () => this.resize()); window.addEventListener("resize", () => this.resize()); // Initialize visibility states this.getLinkClasses().forEach( (item) => (this.edgeTypeVisibility[item] = true) ); this.getNodeClasses().forEach( (item) => (this.nodeTypeVisibility[item] = true) ); 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. */ getNodeColor(node) { return this.highlightNodes.has(node) ? node === this.hoverNode ? "rgb(255,0,0,1)" : "rgba(255,160,0,0.8)" : "rgba(0,255,255,0.6)"; } getLinkColor(link) { if ("type" in link) { return this.edgeColors[link.type]; } return "rgb(255, 255, 255)"; } getLinkWidth(link) { return this.highlightLinks.has(link) ? 2 : 0.8; } /** * Returns an array containing the different edge types of the graph. * @returns {*[]} */ getLinkClasses() { const linkClasses = []; 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(); } focusOnNode(node) { if (typeof node == "string") { node = this.idToNode[node]; } // Aim at node from outside it const distance = 250; const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z); this.graph.cameraPosition( { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio, }, // new position node, // lookAt ({ x, y, z }) 1000 // ms transition duration ); } 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.updateGraphData(); this.removeFloatingLinks(); 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); 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); } updateGraphData() { const data = { nodes: this.gData.nodes.filter( (l) => this.nodeTypeVisibility[l.type] ), links: this.gData.links.filter( (l) => this.edgeTypeVisibility[l.type] ), }; this.graph.graphData(data); } /** * 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. */ updateNodeData() { const gData = this.graph.graphData(); // cross-link node objects this.resetNodeData(); gData.links.forEach((link) => { const a = link.source; const b = link.target; a.neighbors.push(b); b.neighbors.push(a); a.links.push(link); b.links.push(link); }); this.graph.graphData(gData); } 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(); gData.nodes.forEach((node) => { this.idToNode[node.id] = node; }); } resize() { if (document.fullscreenElement == Helpers.getCanvasDivNode()) { 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 */ mapLinkColors() { const linkClasses = this.getLinkClasses(); 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"); const group = new THREE.Group(); 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); group.add(cssobj); // Draw node circle image const textureLoader = new THREE.TextureLoader(); textureLoader.setCrossOrigin("anonymous"); const imageAlpha = textureLoader.load( Config.PLUGIN_PATH + "datasets/images/alpha.png" ); let imageTexture = null; if ("image" in node) { if (node.image.startsWith("http")) { imageTexture = textureLoader.load(node.image); } else { imageTexture = textureLoader.load( Config.PLUGIN_PATH + "datasets/images/" + node.image ); } } else { imageTexture = textureLoader.load( 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. if ("image" in node) { sprite.scale.set(20, 20); } else { sprite.scale.set(5, 5); } group.add(sprite); return group; } }