3d-force-graph
Version:
UI component for a 3D force-directed graph using ThreeJS and ngraph.forcelayout3d layout engine
252 lines (214 loc) • 7.95 kB
JavaScript
import {
WebGLRenderer,
Scene,
PerspectiveCamera,
AmbientLight,
DirectionalLight,
Raycaster,
Vector2,
Color
} from 'three';
const three = window.THREE
? window.THREE // Prefer consumption from global THREE, if exists
: {
WebGLRenderer,
Scene,
PerspectiveCamera,
AmbientLight,
DirectionalLight,
Raycaster,
Vector2,
Color
};
import ThreeTrackballControls from 'three-trackballcontrols';
import ThreeForceGraph from 'three-forcegraph';
import accessorFn from 'accessor-fn';
import Kapsule from 'kapsule';
import linkKapsule from './kapsule-link.js';
import colorStr2Hex from './color2hex.js';
//
const CAMERA_DISTANCE2NODES_FACTOR = 150;
//
// Expose config from forceGraph
const bindFG = linkKapsule('forceGraph', ThreeForceGraph);
const linkedFGProps = Object.assign(...[
'jsonUrl',
'graphData',
'numDimensions',
'nodeRelSize',
'nodeId',
'nodeVal',
'nodeResolution',
'nodeColor',
'nodeAutoColorBy',
'nodeOpacity',
'nodeThreeObject',
'linkSource',
'linkTarget',
'linkColor',
'linkAutoColorBy',
'linkOpacity',
'forceEngine',
'd3AlphaDecay',
'd3VelocityDecay',
'warmupTicks',
'cooldownTicks',
'cooldownTime'
].map(p => ({ [p]: bindFG.linkProp(p)})));
const linkedFGMethods = Object.assign(...[
'd3Force'
].map(p => ({ [p]: bindFG.linkMethod(p)})));
//
export default Kapsule({
props: {
width: { default: window.innerWidth },
height: { default: window.innerHeight },
backgroundColor: {
default: '#000011',
onChange(bckgColor, state) { state.scene.background = new three.Color(colorStr2Hex(bckgColor)); },
triggerUpdate: false
},
showNavInfo: { default: true },
nodeLabel: { default: 'name', triggerUpdate: false },
linkLabel: { default: 'name', triggerUpdate: false },
linkHoverPrecision: { default: 1, triggerUpdate: false },
enablePointerInteraction: { default: true, onChange(_, state) { state.onHover = null; }, triggerUpdate: false },
onNodeClick: { default: () => {}, triggerUpdate: false },
onNodeHover: { default: () => {}, triggerUpdate: false },
onLinkClick: { default: () => {}, triggerUpdate: false },
onLinkHover: { default: () => {}, triggerUpdate: false },
...linkedFGProps
},
aliases: { // Prop names supported for backwards compatibility
nameField: 'nodeLabel',
idField: 'nodeId',
valField: 'nodeVal',
colorField: 'nodeColor',
autoColorBy: 'nodeAutoColorBy',
linkSourceField: 'linkSource',
linkTargetField: 'linkTarget',
linkColorField: 'linkColor',
lineOpacity: 'linkOpacity'
},
methods: {
...linkedFGMethods
},
stateInit: () => ({
renderer: new three.WebGLRenderer(),
scene: new three.Scene(),
camera: new three.PerspectiveCamera(),
lastSetCameraZ: 0,
forceGraph: new ThreeForceGraph()
}),
init: function(domNode, state) {
// Wipe DOM
domNode.innerHTML = '';
// Add nav info section
domNode.appendChild(state.navInfo = document.createElement('div'));
state.navInfo.className = 'graph-nav-info';
state.navInfo.textContent = "MOVE mouse & press LEFT/A: rotate, MIDDLE/S: zoom, RIGHT/D: pan";
// Add info space
let infoElem;
domNode.appendChild(infoElem = document.createElement('div'));
infoElem.className = 'graph-info-msg';
infoElem.textContent = '';
state.forceGraph.onLoading(() => { infoElem.textContent = 'Loading...' });
state.forceGraph.onFinishLoading(() => {
infoElem.textContent = '';
// re-aim camera, if still in default position (not user modified)
if (state.camera.position.x === 0 && state.camera.position.y === 0 && state.camera.position.z === state.lastSetCameraZ) {
state.camera.lookAt(state.forceGraph.position);
state.lastSetCameraZ = state.camera.position.z = Math.cbrt(state.forceGraph.graphData().nodes.length) * CAMERA_DISTANCE2NODES_FACTOR;
}
});
// 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);
// Handle click events on nodes
domNode.addEventListener("click", ev => {
if (state.hoverObj) {
state[`on${state.hoverObj.__graphObjType === 'node' ? 'Node' : 'Link'}Click`](state.hoverObj.__data);
}
}, false);
// Setup renderer, camera and controls
domNode.appendChild(state.renderer.domElement);
const tbControls = new ThreeTrackballControls(state.camera, state.renderer.domElement);
state.renderer.setSize(state.width, state.height);
state.camera.far = 20000;
// Populate scene
state.scene.add(state.forceGraph);
state.scene.add(new three.AmbientLight(0xbbbbbb));
state.scene.add(new three.DirectionalLight(0xffffff, 0.6));
//
// Kick-off renderer
(function animate() { // IIFE
if (state.enablePointerInteraction) {
// Update tooltip and trigger onHover events
raycaster.linePrecision = state.linkHoverPrecision;
raycaster.setFromCamera(mousePos, state.camera);
const intersects = raycaster.intersectObjects(state.forceGraph.children)
.filter(o => ['node', 'link'].indexOf(o.object.__graphObjType) !== -1) // Check only node/link objects
.sort((a, b) => { // Prioritize nodes over links
const isNode = o => o.object.__graphObjType === 'node';
return isNode(b) - isNode(a);
});
const topObject = intersects.length ? intersects[0].object : null;
if (topObject !== state.hoverObj) {
const prevObjType = state.hoverObj ? state.hoverObj.__graphObjType : null;
const prevObjData = state.hoverObj ? state.hoverObj.__data : null;
const objType = topObject ? topObject.__graphObjType : null;
const objData = topObject ? topObject.__data : null;
if (prevObjType && prevObjType !== objType) {
// Hover out
state[`on${prevObjType === 'node' ? 'Node' : 'Link'}Hover`](null, prevObjData);
}
if (objType) {
// Hover in
state[`on${objType === 'node' ? 'Node' : 'Link'}Hover`](objData, prevObjType === objType ? prevObjData : null);
}
toolTipElem.textContent = topObject ? accessorFn(state[`${objType}Label`])(objData) : '';
state.hoverObj = topObject;
}
}
// Frame cycle
state.forceGraph.tickFrame();
tbControls.update();
state.renderer.render(state.scene, state.camera);
requestAnimationFrame(animate);
})();
},
update: function updateFn(state) {
// resize canvas
if (state.width && state.height) {
state.renderer.setSize(state.width, state.height);
state.camera.aspect = state.width/state.height;
state.camera.updateProjectionMatrix();
}
state.navInfo.style.display = state.showNavInfo ? null : 'none';
}
});