import * as Config from "../config"; import * as Helpers from "./helpers"; import { NodeInfoOverlay } from "./overlays/nodeinfo"; import { LinkSelectionOverlay } from "./overlays/linkselection"; 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"; class Graph { constructor(dataUrl) { this.graph = null; this.gData = null; this.highlightNodes = new Set(); this.highlightLinks = new Set(); this.hoverNode = null; this.edgeColors = {}; this.idToNode = {}; this.firstTick = true; this.infooverlay = null; this.edgeTypeVisibility = {}; this.loadGraph(dataUrl); } async loadGraph(dataUrl) { this.gData = await fetch(dataUrl).then((res) => res.json()); this.graph = ForceGraph3D({ extraRenderers: [new CSS2DRenderer(), new CSS3DRenderer()], })(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)) .linkOpacity(0.8) .nodeThreeObjectExtend(false) .nodeThreeObject((node) => this.drawNode(node)) .onEngineTick(() => this.initializeModel()) .width(Helpers.getWidth()) .height(Helpers.getHeight()); } initializeModel() { if (this.firstTick) { this.mapEdgeColors(); this.updateNodeData(); this.updateNodeMap(); this.addBackground(); loadComponents(); // Catch resize events document.addEventListener("fullscreenchange", () => this.resize()); window.addEventListener("resize", () => this.resize()); this.getLinkClasses().forEach( (item) => (this.edgeTypeVisibility[item] = true) ); this.firstTick = false; } } 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; } getLinkClasses() { const linkClasses = []; this.graph .graphData() .links.forEach((link) => linkClasses.push(link.type)); return [...new Set(linkClasses)]; } 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); } } hideLinkType(type) { this.edgeTypeVisibility[type] = false; this.updateGraphData(); this.updateNodeData(); this.removeFloatingNodes(); } showLinkType(type) { this.edgeTypeVisibility[type] = true; this.updateGraphData(); 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); } updateGraphData() { const data = { nodes: this.gData.nodes, links: this.gData.links.filter( (l) => this.edgeTypeVisibility[l.type] ), }; this.graph.graphData(data); } resetNodeData() { const gData = this.graph.graphData(); for (const node of gData.nodes) { node.neighbors = []; node.links = []; } } 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); } mapEdgeColors() { const linkClasses = this.getLinkClasses(); for (let i = 0; i < linkClasses.length; i++) { this.edgeColors[linkClasses[i]] = Config.COLOR_PALETTE[i % Config.COLOR_PALETTE.length]; } } drawNode(node) { // Draw node as label + image const nodeDiv = document.createElement("div"); const group = new THREE.Group(); const labelDiv = document.createElement("div"); labelDiv.textContent = node.name; labelDiv.style.color = node.color; labelDiv.className = "node-label"; nodeDiv.appendChild(labelDiv); 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(); const imageAlpha = textureLoader.load( Config.PLUGIN_PATH + "datasets/images/alpha.png" ); let imageTexture = null; if ("image" in node) { 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; } } function loadComponents() { linkoverlay.create(); infooverlay.create(); createFullScreenButton(); } function createFullScreenButton() { const sceneNode = Helpers.getCanvasDivNode(); const overlayNode = document.createElement("div"); overlayNode.className = "fullscreen-button"; overlayNode.innerHTML = "<p>⤢</p>"; overlayNode.addEventListener("click", function () { if (!document.fullscreenElement) { Helpers.getCanvasDivNode().requestFullscreen(); } else { document.exitFullscreen(); } G.resize(); }); sceneNode.appendChild(overlayNode); } const dataUrl = Config.PLUGIN_PATH + "datasets/aud1.json"; const G = new Graph(dataUrl); const linkoverlay = new LinkSelectionOverlay(G); const infooverlay = new NodeInfoOverlay(G); G.infooverlay = infooverlay;