const CAMERA_DISTANCE2NODES_FACTOR = 150; const ForceGraph = SWC.createComponent({ props: [ new SWC.Prop('width', window.innerWidth), new SWC.Prop('height', window.innerHeight), new SWC.Prop('graphData', { nodes: {}, links: [] // [from, to] }), new SWC.Prop('numDimensions', 3), new SWC.Prop('nodeRelSize', 4), // volume per val unit new SWC.Prop('lineOpacity', 0.2), new SWC.Prop('valAccessor', node => node.val), new SWC.Prop('nameAccessor', node => node.name), new SWC.Prop('colorAccessor', node => node.color), new SWC.Prop('warmUpTicks', 0), // how many times to tick the force engine at init before starting to render new SWC.Prop('coolDownTicks', Infinity), new SWC.Prop('coolDownTime', 15000) // ms ], init: (domNode, state) => { // Wipe DOM domNode.innerHTML = ''; // Add nav info section const navInfo = document.createElement('div'); navInfo.classList.add('graph-nav-info'); navInfo.innerHTML = "MOVE mouse & press LEFT/A: rotate, MIDDLE/S: zoom, RIGHT/D: pan"; domNode.appendChild(navInfo); // Setup tooltip const toolTipElem = document.createElement('div'); toolTipElem.classList.add('graph-tooltip'); domNode.appendChild(toolTipElem); // Capture mouse coords on move const raycaster = new THREE.Raycaster(); const mousePos = new THREE.Vector2(); mousePos.x = -2; // Initialize off canvas mousePos.y = -2; domNode.addEventListener("mousemove", ev => { // update the mouse pos const offset = getOffset(domNode), relPos = { x: ev.pageX - offset.left, y: ev.pageY - offset.top }; mousePos.x = (relPos.x / state.width) * 2 - 1; mousePos.y = -(relPos.y / state.height) * 2 + 1; // Move tooltip toolTipElem.style.top = (relPos.y - 40) + 'px'; toolTipElem.style.left = (relPos.x - 20) + 'px'; function getOffset(el) { const rect = el.getBoundingClientRect(), scrollLeft = window.pageXOffset || document.documentElement.scrollLeft, scrollTop = window.pageYOffset || document.documentElement.scrollTop; return { top: rect.top + scrollTop, left: rect.left + scrollLeft }; } }, false); // Setup camera state.camera = new THREE.PerspectiveCamera(); state.camera.far = 20000; state.camera.position.z = 1000; // Setup scene const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0000A); scene.add(state.graphScene = new THREE.Group()); // Add lights scene.add(new THREE.AmbientLight(0xbbbbbb)); scene.add(new THREE.DirectionalLight(0xffffff, 0.6)); // Setup renderer state.renderer = new THREE.WebGLRenderer(); domNode.appendChild(state.renderer.domElement); // Add camera interaction const tbControls = new TrackballControls(state.camera, state.renderer.domElement); // // Kick-off renderer (function animate() { // IIFE // Update tooltip raycaster.setFromCamera(mousePos, state.camera); const intersects = raycaster.intersectObjects(state.graphScene.children); toolTipElem.innerHTML = intersects.length ? intersects[0].object.name || '' : ''; // Frame cycle tbControls.update(); state.renderer.render(scene, state.camera); requestAnimationFrame(animate); })(); }, update: state => { resizeCanvas(); while (state.graphScene.children.length) { state.graphScene.remove(state.graphScene.children[0]) } // Clear the place // Build graph with data const d3Nodes = []; for (let nodeId in state.graphData.nodes) { // Turn nodes into array const node = state.graphData.nodes[nodeId]; node._id = nodeId; d3Nodes.push(node); } const d3Links = state.graphData.links.map(link => { return { source: link[0], target: link[1] }; }); // Add WebGL objects d3Nodes.forEach(node => { const nodeMaterial = new THREE.MeshLambertMaterial({ color: state.colorAccessor(node) || 0xffffaa, transparent: true }); nodeMaterial.opacity = 0.75; const sphere = new THREE.Mesh( new THREE.SphereGeometry(Math.cbrt(state.valAccessor(node) || 1) * state.nodeRelSize, 8, 8), nodeMaterial ); sphere.name = state.nameAccessor(node) || ''; state.graphScene.add(node._sphere = sphere) }); const lineMaterial = new THREE.LineBasicMaterial({ color: 0xf0f0f0, transparent: true }); lineMaterial.opacity = state.lineOpacity; d3Links.forEach(link => { const line = new THREE.Line(new THREE.Geometry(), lineMaterial); line.geometry.vertices=[new THREE.Vector3(0,0,0), new THREE.Vector3(0,0,0)]; line.renderOrder = 10; // Prevent visual glitches of dark lines on top of spheres by rendering them last const fromName = getNodeName(link.source), toName = getNodeName(link.target); if (fromName && toName) { line.name = `${fromName} > ${toName}`; } state.graphScene.add(link._line = line); function getNodeName(nodeId) { return state.nameAccessor(state.graphData.nodes[nodeId]); } }); state.camera.lookAt(state.graphScene.position); state.camera.position.z = Math.cbrt(d3Nodes.length) * CAMERA_DISTANCE2NODES_FACTOR; // Add force-directed layout const layout = d3_force.forceSimulation() .numDimensions(state.numDimensions) .nodes(d3Nodes) .force('link', d3_force.forceLink().id(d => d._id).links(d3Links)) .force('charge', d3_force.forceManyBody()) .force('center', d3_force.forceCenter()) .stop(); for (let i=0; i state.coolDownTicks || (new Date()) - startTickTime > state.coolDownTime) { layout.stop(); // Stop ticking graph } // Update nodes position d3Nodes.forEach(node => { const sphere = node._sphere; sphere.position.x = node.x; sphere.position.y = node.y || 0; sphere.position.z = node.z || 0; }); // Update links position d3Links.forEach(link => { const line = link._line; line.geometry.vertices = [ new THREE.Vector3(link.source.x, link.source.y || 0, link.source.z || 0), new THREE.Vector3(link.target.x, link.target.y || 0, link.target.z || 0) ]; line.geometry.verticesNeedUpdate = true; line.geometry.computeBoundingSphere(); }); } } });