import { AmbientLight, DirectionalLight, Vector3, REVISION } from 'three'; const three = window.THREE ? window.THREE // Prefer consumption from global THREE, if exists : { AmbientLight, DirectionalLight, Vector3, REVISION }; import { DragControls as ThreeDragControls } from 'three/examples/jsm/controls/DragControls.js'; import ThreeForceGraph from 'three-forcegraph'; import ThreeRenderObjects from 'three-render-objects'; import accessorFn from 'accessor-fn'; import Kapsule from 'kapsule'; import linkKapsule from './kapsule-link.js'; // const CAMERA_DISTANCE2NODES_FACTOR = 170; // // Expose config from forceGraph const bindFG = linkKapsule('forceGraph', ThreeForceGraph); const linkedFGProps = Object.assign(...[ 'jsonUrl', 'graphData', 'numDimensions', 'dagMode', 'dagLevelDistance', 'dagNodeFilter', 'onDagError', 'nodeRelSize', 'nodeId', 'nodeVal', 'nodeResolution', 'nodeColor', 'nodeAutoColorBy', 'nodeOpacity', 'nodeVisibility', 'nodeThreeObject', 'nodeThreeObjectExtend', 'linkSource', 'linkTarget', 'linkVisibility', 'linkColor', 'linkAutoColorBy', 'linkOpacity', 'linkWidth', 'linkResolution', 'linkCurvature', 'linkCurveRotation', 'linkMaterial', 'linkThreeObject', 'linkThreeObjectExtend', 'linkPositionUpdate', 'linkDirectionalArrowLength', 'linkDirectionalArrowColor', 'linkDirectionalArrowRelPos', 'linkDirectionalArrowResolution', 'linkDirectionalParticles', 'linkDirectionalParticleSpeed', 'linkDirectionalParticleWidth', 'linkDirectionalParticleColor', 'linkDirectionalParticleResolution', 'forceEngine', 'd3AlphaDecay', 'd3VelocityDecay', 'd3AlphaMin', 'ngraphPhysics', 'warmupTicks', 'cooldownTicks', 'cooldownTime', 'onEngineTick', 'onEngineStop' ].map(p => ({ [p]: bindFG.linkProp(p)}))); const linkedFGMethods = Object.assign(...[ 'refresh', 'getGraphBbox', 'd3Force', 'd3ReheatSimulation', 'emitParticle' ].map(p => ({ [p]: bindFG.linkMethod(p)}))); // Expose config from renderObjs const bindRenderObjs = linkKapsule('renderObjs', ThreeRenderObjects); const linkedRenderObjsProps = Object.assign(...[ 'width', 'height', 'backgroundColor', 'showNavInfo', 'enablePointerInteraction' ].map(p => ({ [p]: bindRenderObjs.linkProp(p)}))); const linkedRenderObjsMethods = Object.assign( ...[ 'lights', 'cameraPosition', 'postProcessingComposer' ].map(p => ({ [p]: bindRenderObjs.linkMethod(p)})), { graph2ScreenCoords: bindRenderObjs.linkMethod('getScreenCoords'), screen2GraphCoords: bindRenderObjs.linkMethod('getSceneCoords') } ); // export default Kapsule({ props: { nodeLabel: { default: 'name', triggerUpdate: false }, linkLabel: { default: 'name', triggerUpdate: false }, linkHoverPrecision: { default: 1, onChange: (p, state) => state.renderObjs.lineHoverPrecision(p), triggerUpdate: false }, enableNavigationControls: { default: true, onChange(enable, state) { const controls = state.renderObjs.controls(); if (controls) { controls.enabled = enable; // trigger mouseup on re-enable to prevent sticky controls enable && controls.domElement && controls.domElement.dispatchEvent(new PointerEvent('pointerup')); } }, triggerUpdate: false }, enableNodeDrag: { default: true, triggerUpdate: false }, onNodeDrag: { default: () => {}, triggerUpdate: false }, onNodeDragEnd: { default: () => {}, triggerUpdate: false }, onNodeClick: { triggerUpdate: false }, onNodeRightClick: { triggerUpdate: false }, onNodeHover: { triggerUpdate: false }, onLinkClick: { triggerUpdate: false }, onLinkRightClick: { triggerUpdate: false }, onLinkHover: { triggerUpdate: false }, onBackgroundClick: { triggerUpdate: false }, onBackgroundRightClick: { triggerUpdate: false }, ...linkedFGProps, ...linkedRenderObjsProps }, methods: { zoomToFit: function(state, transitionDuration, padding, ...bboxArgs) { state.renderObjs.fitToBbox( state.forceGraph.getGraphBbox(...bboxArgs), transitionDuration, padding ); return this; }, pauseAnimation: function(state) { if (state.animationFrameRequestId !== null) { cancelAnimationFrame(state.animationFrameRequestId); state.animationFrameRequestId = null; } return this; }, resumeAnimation: function(state) { if (state.animationFrameRequestId === null) { this._animationCycle(); } return this; }, _animationCycle(state) { if (state.enablePointerInteraction) { // reset canvas cursor (override dragControls cursor) this.renderer().domElement.style.cursor = null; } // Frame cycle state.forceGraph.tickFrame(); state.renderObjs.tick(); state.animationFrameRequestId = requestAnimationFrame(this._animationCycle); }, scene: state => state.renderObjs.scene(), // Expose scene camera: state => state.renderObjs.camera(), // Expose camera renderer: state => state.renderObjs.renderer(), // Expose renderer controls: state => state.renderObjs.controls(), // Expose controls tbControls: state => state.renderObjs.tbControls(), // To be deprecated _destructor: function() { this.pauseAnimation(); this.graphData({ nodes: [], links: []}); }, ...linkedFGMethods, ...linkedRenderObjsMethods }, stateInit: ({ controlType, rendererConfig, extraRenderers }) => { const forceGraph = new ThreeForceGraph(); return { forceGraph, renderObjs: ThreeRenderObjects({ controlType, rendererConfig, extraRenderers }) .objects([forceGraph]) // Populate scene .lights([ new three.AmbientLight(0xcccccc, Math.PI), new three.DirectionalLight(0xffffff, 0.6 * Math.PI) ]) } }, init: function(domNode, state) { // Wipe DOM domNode.innerHTML = ''; // Add relative container domNode.appendChild(state.container = document.createElement('div')); state.container.style.position = 'relative'; // Add renderObjs const roDomNode = document.createElement('div'); state.container.appendChild(roDomNode); state.renderObjs(roDomNode); const camera = state.renderObjs.camera(); const renderer = state.renderObjs.renderer(); const controls = state.renderObjs.controls(); controls.enabled = !!state.enableNavigationControls; state.lastSetCameraZ = camera.position.z; // Add info space let infoElem; state.container.appendChild(infoElem = document.createElement('div')); infoElem.className = 'graph-info-msg'; infoElem.textContent = ''; // config forcegraph state.forceGraph .onLoading(() => { infoElem.textContent = 'Loading...' }) .onFinishLoading(() => { infoElem.textContent = '' }) .onUpdate(() => { // sync graph data structures state.graphData = state.forceGraph.graphData(); // re-aim camera, if still in default position (not user modified) if (camera.position.x === 0 && camera.position.y === 0 && camera.position.z === state.lastSetCameraZ && state.graphData.nodes.length) { camera.lookAt(state.forceGraph.position); state.lastSetCameraZ = camera.position.z = Math.cbrt(state.graphData.nodes.length) * CAMERA_DISTANCE2NODES_FACTOR; } }) .onFinishUpdate(() => { // Setup node drag interaction if (state._dragControls) { const curNodeDrag = state.graphData.nodes.find(node => node.__initialFixedPos && !node.__disposeControlsAfterDrag); // detect if there's a node being dragged using the existing drag controls if (curNodeDrag) { curNodeDrag.__disposeControlsAfterDrag = true; // postpone previous controls disposal until drag ends } else { state._dragControls.dispose(); // cancel previous drag controls } state._dragControls = undefined; } if (state.enableNodeDrag && state.enablePointerInteraction && state.forceEngine === 'd3') { // Can't access node positions programmatically in ngraph const dragControls = state._dragControls = new ThreeDragControls( state.graphData.nodes.map(node => node.__threeObj).filter(obj => obj), camera, renderer.domElement ); dragControls.addEventListener('dragstart', function (event) { const nodeObj = getGraphObj(event.object); if (!nodeObj) return; controls.enabled = false; // Disable controls while dragging // track drag object movement event.object.__initialPos = event.object.position.clone(); event.object.__prevPos = event.object.position.clone(); const node = nodeObj.__data; !node.__initialFixedPos && (node.__initialFixedPos = {fx: node.fx, fy: node.fy, fz: node.fz}); !node.__initialPos && (node.__initialPos = {x: node.x, y: node.y, z: node.z}); // lock node ['x', 'y', 'z'].forEach(c => node[`f${c}`] = node[c]); // drag cursor renderer.domElement.classList.add('grabbable'); }); dragControls.addEventListener('drag', function (event) { const nodeObj = getGraphObj(event.object); if (!nodeObj) return; if (!event.object.hasOwnProperty('__graphObjType')) { // If dragging a child of the node, update the node object instead const initPos = event.object.__initialPos; const prevPos = event.object.__prevPos; const newPos = event.object.position; nodeObj.position.add(newPos.clone().sub(prevPos)); // translate node object by the motion delta prevPos.copy(newPos); newPos.copy(initPos); // reset child back to its initial position } const node = nodeObj.__data; const newPos = nodeObj.position; const translate = {x: newPos.x - node.x, y: newPos.y - node.y, z: newPos.z - node.z}; // Move fx/fy/fz (and x/y/z) of nodes based on object new position ['x', 'y', 'z'].forEach(c => node[`f${c}`] = node[c] = newPos[c]); state.forceGraph .d3AlphaTarget(0.3) // keep engine running at low intensity throughout drag .resetCountdown(); // prevent freeze while dragging node.__dragged = true; state.onNodeDrag(node, translate); }); dragControls.addEventListener('dragend', function (event) { const nodeObj = getGraphObj(event.object); if (!nodeObj) return; delete(event.object.__initialPos); // remove tracking attributes delete(event.object.__prevPos); const node = nodeObj.__data; // dispose previous controls if needed if (node.__disposeControlsAfterDrag) { dragControls.dispose(); delete(node.__disposeControlsAfterDrag); } const initFixedPos = node.__initialFixedPos; const initPos = node.__initialPos; const translate = {x: initPos.x - node.x, y: initPos.y - node.y, z: initPos.z - node.z}; if (initFixedPos) { ['x', 'y', 'z'].forEach(c => { const fc = `f${c}`; if (initFixedPos[fc] === undefined) { delete(node[fc]) } }); delete(node.__initialFixedPos); delete(node.__initialPos); if (node.__dragged) { delete(node.__dragged); state.onNodeDragEnd(node, translate); } } state.forceGraph .d3AlphaTarget(0) // release engine low intensity .resetCountdown(); // let the engine readjust after releasing fixed nodes if (state.enableNavigationControls) { controls.enabled = true; // Re-enable controls controls.domElement && controls.domElement.ownerDocument && controls.domElement.ownerDocument.dispatchEvent( // simulate mouseup to ensure the controls don't take over after dragend new PointerEvent('pointerup', { pointerType: 'touch' }) ); } // clear cursor renderer.domElement.classList.remove('grabbable'); }); } }); // config renderObjs three.REVISION < 155 && (state.renderObjs.renderer().useLegacyLights = false); // force behavior for three < 155 state.renderObjs .hoverOrderComparator((a, b) => { // Prioritize graph objects const aObj = getGraphObj(a); if (!aObj) return 1; const bObj = getGraphObj(b); if (!bObj) return -1; // Prioritize nodes over links const isNode = o => o.__graphObjType === 'node'; return isNode(bObj) - isNode(aObj); }) .tooltipContent(obj => { const graphObj = getGraphObj(obj); return graphObj ? accessorFn(state[`${graphObj.__graphObjType}Label`])(graphObj.__data) || '' : ''; }) .hoverDuringDrag(false) .onHover(obj => { // Update tooltip and trigger onHover events const hoverObj = getGraphObj(obj); if (hoverObj !== state.hoverObj) { const prevObjType = state.hoverObj ? state.hoverObj.__graphObjType : null; const prevObjData = state.hoverObj ? state.hoverObj.__data : null; const objType = hoverObj ? hoverObj.__graphObjType : null; const objData = hoverObj ? hoverObj.__data : null; if (prevObjType && prevObjType !== objType) { // Hover out const fn = state[`on${prevObjType === 'node' ? 'Node' : 'Link'}Hover`]; fn && fn(null, prevObjData); } if (objType) { // Hover in const fn = state[`on${objType === 'node' ? 'Node' : 'Link'}Hover`]; fn && fn(objData, prevObjType === objType ? prevObjData : null); } // set pointer if hovered object is clickable renderer.domElement.classList[ ((hoverObj && state[`on${objType === 'node' ? 'Node' : 'Link'}Click`]) || (!hoverObj && state.onBackgroundClick)) ? 'add' : 'remove' ]('clickable'); state.hoverObj = hoverObj; } }) .clickAfterDrag(false) .onClick((obj, ev) => { const graphObj = getGraphObj(obj); if (graphObj) { const fn = state[`on${graphObj.__graphObjType === 'node' ? 'Node' : 'Link'}Click`]; fn && fn(graphObj.__data, ev); } else { state.onBackgroundClick && state.onBackgroundClick(ev); } }) .onRightClick((obj, ev) => { // Handle right-click events const graphObj = getGraphObj(obj); if (graphObj) { const fn = state[`on${graphObj.__graphObjType === 'node' ? 'Node' : 'Link'}RightClick`]; fn && fn(graphObj.__data, ev); } else { state.onBackgroundRightClick && state.onBackgroundRightClick(ev); } }); // // Kick-off renderer this._animationCycle(); } }); // function getGraphObj(object) { let obj = object; // recurse up object chain until finding the graph object while (obj && !obj.hasOwnProperty('__graphObjType')) { obj = obj.parent; } return obj; }